@kenyaemr/esm-patient-clinical-view-app 5.4.2-pre.2714 → 5.4.2-pre.2722

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 (66) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/dist/805.js +1 -0
  3. package/dist/805.js.map +1 -0
  4. package/dist/kenyaemr-esm-patient-clinical-view-app.js +2 -2
  5. package/dist/kenyaemr-esm-patient-clinical-view-app.js.buildmanifest.json +27 -27
  6. package/dist/main.js +27 -27
  7. package/dist/main.js.map +1 -1
  8. package/dist/routes.json +1 -1
  9. package/package.json +1 -1
  10. package/src/config-schema.ts +97 -0
  11. package/src/contact-list/contact-tracing-history.component.tsx +18 -15
  12. package/src/maternal-and-child-health/partography/components/pulse-bp-graph.component.tsx +1 -0
  13. package/src/maternal-and-child-health/partography/components/temperature-graph.component.tsx +218 -0
  14. package/src/maternal-and-child-health/partography/components/uterine-contractions-graph.component.tsx +209 -0
  15. package/src/maternal-and-child-health/partography/forms/cervical-contractions-form.component.tsx +211 -0
  16. package/src/maternal-and-child-health/partography/forms/cervix-form.component.tsx +354 -0
  17. package/src/maternal-and-child-health/partography/forms/drugs-iv-fluids-form.component.tsx +321 -0
  18. package/src/maternal-and-child-health/partography/forms/fetal-heart-rate-form.component.tsx +275 -0
  19. package/src/maternal-and-child-health/partography/forms/index.ts +9 -0
  20. package/src/maternal-and-child-health/partography/forms/membrane-amniotic-fluid-form.component.tsx +330 -0
  21. package/src/maternal-and-child-health/partography/forms/oxytocin-form.component.tsx +207 -0
  22. package/src/maternal-and-child-health/partography/forms/pulse-bp-form.component.tsx +174 -0
  23. package/src/maternal-and-child-health/partography/forms/temperature-form.component.tsx +210 -0
  24. package/src/maternal-and-child-health/partography/forms/time-picker-dropdown.component.tsx +218 -0
  25. package/src/maternal-and-child-health/partography/forms/time-picker-dropdown.scss +107 -0
  26. package/src/maternal-and-child-health/partography/forms/time-picker-with-clock.component.tsx +174 -0
  27. package/src/maternal-and-child-health/partography/forms/time-picker-with-clock.scss +178 -0
  28. package/src/maternal-and-child-health/partography/forms/urine-test-form.component.tsx +255 -0
  29. package/src/maternal-and-child-health/partography/forms/useCervixData.ts +16 -0
  30. package/src/maternal-and-child-health/partography/graphs/cervical-contractions-graph.component.tsx +266 -0
  31. package/src/maternal-and-child-health/partography/graphs/cervix-graph.component.tsx +429 -0
  32. package/src/maternal-and-child-health/partography/graphs/drugs-iv-fluids-graph-wrapper.component.tsx +163 -0
  33. package/src/maternal-and-child-health/partography/graphs/drugs-iv-fluids-graph.component.tsx +82 -0
  34. package/src/maternal-and-child-health/partography/graphs/fetal-heart-rate-graph.component.tsx +359 -0
  35. package/src/maternal-and-child-health/partography/graphs/index.ts +10 -0
  36. package/src/maternal-and-child-health/partography/graphs/membrane-amniotic-fluid-graph.component.tsx +266 -0
  37. package/src/maternal-and-child-health/partography/graphs/oxytocin-graph-wrapper.component.tsx +190 -0
  38. package/src/maternal-and-child-health/partography/graphs/oxytocin-graph.component.tsx +126 -0
  39. package/src/maternal-and-child-health/partography/graphs/partograph-graph.component.tsx +266 -0
  40. package/src/maternal-and-child-health/partography/graphs/pulse-bp-graph-wrapper.component.tsx +298 -0
  41. package/src/maternal-and-child-health/partography/graphs/pulse-bp-graph.component.tsx +267 -0
  42. package/src/maternal-and-child-health/partography/graphs/temperature-graph.component.tsx +242 -0
  43. package/src/maternal-and-child-health/partography/graphs/urine-test-graph.component.tsx +246 -0
  44. package/src/maternal-and-child-health/partography/partograph.component.tsx +2141 -118
  45. package/src/maternal-and-child-health/partography/partography-dashboard.meta.ts +8 -0
  46. package/src/maternal-and-child-health/partography/partography-data-form.scss +163 -0
  47. package/src/maternal-and-child-health/partography/partography.resource.ts +233 -326
  48. package/src/maternal-and-child-health/partography/partography.scss +1341 -3
  49. package/src/maternal-and-child-health/partography/resources/blood-pressure.resource.ts +96 -0
  50. package/src/maternal-and-child-health/partography/resources/cervical-dilation.resource.ts +109 -0
  51. package/src/maternal-and-child-health/partography/resources/cervix.resource.ts +362 -0
  52. package/src/maternal-and-child-health/partography/resources/descent-of-head.resource.ts +101 -0
  53. package/src/maternal-and-child-health/partography/resources/drugs-fluids.resource.ts +88 -0
  54. package/src/maternal-and-child-health/partography/resources/fetal-heart-rate.resource.ts +122 -0
  55. package/src/maternal-and-child-health/partography/resources/maternal-pulse.resource.ts +77 -0
  56. package/src/maternal-and-child-health/partography/resources/membrane-amniotic-fluid.resource.ts +108 -0
  57. package/src/maternal-and-child-health/partography/resources/oxytocin.resource.ts +159 -0
  58. package/src/maternal-and-child-health/partography/resources/progress-events.resource.ts +6 -0
  59. package/src/maternal-and-child-health/partography/resources/pulse-bp-combined.resource.ts +53 -0
  60. package/src/maternal-and-child-health/partography/resources/temperature.resource.ts +84 -0
  61. package/src/maternal-and-child-health/partography/resources/uterine-contractions.resource.ts +173 -0
  62. package/src/maternal-and-child-health/partography/table/temperature-table.component.tsx +99 -0
  63. package/src/maternal-and-child-health/partography/table/uterine-contractions-table.component.tsx +86 -0
  64. package/src/maternal-and-child-health/partography/types/index.ts +319 -101
  65. package/dist/397.js +0 -1
  66. package/dist/397.js.map +0 -1
@@ -0,0 +1,96 @@
1
+ import { usePartographyEncounters } from '../partography.resource';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { PARTOGRAPHY_CONCEPTS } from '../types';
4
+ import { toOmrsIsoString } from '@openmrs/esm-framework';
5
+
6
+ export function transformBloodPressureEncounterToChartData(
7
+ encounters: any[],
8
+ t: (key: string, defaultValue?: string, options?: any) => string,
9
+ ): any[] {
10
+ const chartData = [];
11
+ encounters.forEach((encounter) => {
12
+ const encounterTime = new Date(encounter.encounterDatetime).toLocaleTimeString('en-US', {
13
+ hour: '2-digit',
14
+ minute: '2-digit',
15
+ hour12: false,
16
+ });
17
+ encounter.obs?.forEach((obs) => {
18
+ if (obs.concept.uuid === PARTOGRAPHY_CONCEPTS['systolic-bp']) {
19
+ chartData.push({ group: t('Systolic'), time: encounterTime, value: parseFloat(obs.value) });
20
+ }
21
+ if (obs.concept.uuid === PARTOGRAPHY_CONCEPTS['diastolic-bp']) {
22
+ chartData.push({ group: t('Diastolic'), time: encounterTime, value: parseFloat(obs.value) });
23
+ }
24
+ });
25
+ });
26
+ return chartData;
27
+ }
28
+
29
+ export function transformBloodPressureEncounterToTableData(
30
+ encounters: any[],
31
+ t: (key: string, defaultValue?: string, options?: any) => string,
32
+ ): any[] {
33
+ const tableData = [];
34
+ encounters.forEach((encounter, index) => {
35
+ const encounterDate = new Date(encounter.encounterDatetime);
36
+ const dateTime = `${encounterDate.toLocaleDateString()} — ${encounterDate.toLocaleTimeString('en-US', {
37
+ hour: '2-digit',
38
+ minute: '2-digit',
39
+ hour12: false,
40
+ })}`;
41
+ encounter.obs?.forEach((obs, obsIndex) => {
42
+ if (
43
+ obs.concept.uuid === PARTOGRAPHY_CONCEPTS['systolic-bp'] ||
44
+ obs.concept.uuid === PARTOGRAPHY_CONCEPTS['diastolic-bp']
45
+ ) {
46
+ tableData.push({
47
+ id: `blood-pressure-${index}-${obsIndex}`,
48
+ dateTime,
49
+ measurement: obs.concept.uuid === PARTOGRAPHY_CONCEPTS['systolic-bp'] ? t('Systolic') : t('Diastolic'),
50
+ value: parseFloat(obs.value),
51
+ unit: t('mmHg'),
52
+ });
53
+ }
54
+ });
55
+ });
56
+ return tableData;
57
+ }
58
+
59
+ export function useBloodPressureData(patientUuid: string) {
60
+ const { t } = useTranslation();
61
+ const { encounters, isLoading, error, mutate } = usePartographyEncounters(patientUuid, 'blood-pressure');
62
+ let localizedError = error;
63
+ if (error) {
64
+ localizedError = t('Failed to load blood pressure data');
65
+ }
66
+ return { data: encounters, isLoading, error: localizedError, mutate };
67
+ }
68
+
69
+ export function buildBloodPressureObservation(formData: any): any[] {
70
+ const timeConfig = { defaultEncounterOffset: 0 };
71
+ const obsDatetime = toOmrsIsoString(new Date(Date.now() - timeConfig.defaultEncounterOffset));
72
+ const observations = [];
73
+ if (formData.systolic) {
74
+ observations.push({
75
+ concept: PARTOGRAPHY_CONCEPTS['systolic-bp'],
76
+ value: parseFloat(formData.systolic),
77
+ obsDatetime,
78
+ });
79
+ }
80
+ if (formData.diastolic) {
81
+ observations.push({
82
+ concept: PARTOGRAPHY_CONCEPTS['diastolic-bp'],
83
+ value: parseFloat(formData.diastolic),
84
+ obsDatetime,
85
+ });
86
+ }
87
+ // Optional time slot observation for pulse/BP graph (if provided by the form)
88
+ if (formData.time || formData.timeSlot) {
89
+ observations.push({
90
+ concept: PARTOGRAPHY_CONCEPTS['pulse-time-slot'] || PARTOGRAPHY_CONCEPTS['time-slot'],
91
+ value: formData.time || formData.timeSlot,
92
+ obsDatetime,
93
+ });
94
+ }
95
+ return observations;
96
+ }
@@ -0,0 +1,109 @@
1
+ import React from 'react';
2
+ import { openmrsFetch, restBaseUrl, toOmrsIsoString } from '@openmrs/esm-framework';
3
+ import useSWR from 'swr';
4
+ import { MCH_PARTOGRAPHY_ENCOUNTER_UUID, PARTOGRAPHY_CONCEPTS } from '../types';
5
+ import { createPartographyEncounter } from '../partography.resource';
6
+ import { useTranslation } from 'react-i18next';
7
+
8
+ export interface CervicalDilationEntry {
9
+ id: string;
10
+ uuid: string;
11
+ encounterUuid: string;
12
+ cervicalDilation: number;
13
+ amnioticFluid?: string;
14
+ moulding?: string;
15
+ time?: string;
16
+ date: string;
17
+ encounterDatetime: string;
18
+ obsDatetime: string;
19
+ }
20
+
21
+ export function useCervicalDilationData(patientUuid: string) {
22
+ const fetcher = (url: string) => openmrsFetch(url).then((res) => res.json());
23
+ const { data, error, isLoading, mutate } = useSWR(
24
+ patientUuid
25
+ ? `${restBaseUrl}/encounter?patient=${patientUuid}&encounterType=${MCH_PARTOGRAPHY_ENCOUNTER_UUID}&v=full&limit=100&order=desc`
26
+ : null,
27
+ fetcher,
28
+ {
29
+ onError: () => {},
30
+ },
31
+ );
32
+ const cervicalDilationData: CervicalDilationEntry[] = React.useMemo(() => {
33
+ if (!data?.results || !Array.isArray(data.results)) {
34
+ return [];
35
+ }
36
+ const entries: CervicalDilationEntry[] = [];
37
+ for (const encounter of data.results) {
38
+ if (!encounter.obs || !Array.isArray(encounter.obs)) {
39
+ continue;
40
+ }
41
+ const cervicalDilationObs = encounter.obs.find(
42
+ (obs) => obs.concept.uuid === PARTOGRAPHY_CONCEPTS['cervical-dilation'],
43
+ );
44
+ if (cervicalDilationObs) {
45
+ const encounterDatetime = new Date(encounter.encounterDatetime);
46
+ const amnioticFluidObs = encounter.obs.find(
47
+ (obs) => obs.concept.uuid === PARTOGRAPHY_CONCEPTS['amniotic-fluid'],
48
+ );
49
+ const mouldingObs = encounter.obs.find((obs) => obs.concept.uuid === PARTOGRAPHY_CONCEPTS['moulding']);
50
+ const timeObs = encounter.obs.find(
51
+ (obs) =>
52
+ obs.concept.uuid === PARTOGRAPHY_CONCEPTS['fetal-heart-rate-time'] &&
53
+ typeof obs.value === 'string' &&
54
+ obs.value.startsWith('Time:'),
55
+ );
56
+ let time = '';
57
+ if (timeObs && typeof timeObs.value === 'string') {
58
+ const timeMatch = timeObs.value.match(/Time:\s*(.+)/);
59
+ if (timeMatch) {
60
+ time = timeMatch[1].trim();
61
+ }
62
+ }
63
+ entries.push({
64
+ id: `cd-${encounter.uuid}`,
65
+ uuid: encounter.uuid,
66
+ encounterUuid: encounter.uuid,
67
+ cervicalDilation: parseFloat(cervicalDilationObs.value) || 0,
68
+ amnioticFluid: amnioticFluidObs?.value || '',
69
+ moulding: mouldingObs?.value || '',
70
+ time,
71
+ date: encounterDatetime.toLocaleDateString(),
72
+ encounterDatetime: encounterDatetime.toISOString(),
73
+ obsDatetime: cervicalDilationObs.obsDatetime,
74
+ });
75
+ }
76
+ }
77
+ return entries.sort((a, b) => new Date(b.encounterDatetime).getTime() - new Date(a.encounterDatetime).getTime());
78
+ }, [data]);
79
+ return {
80
+ cervicalDilationData,
81
+ isLoading,
82
+ error,
83
+ mutate,
84
+ };
85
+ }
86
+
87
+ export async function saveCervicalDilationData(
88
+ patientUuid: string,
89
+ formData: { cervicalDilation: number; amnioticFluid?: string; moulding?: string; time?: string },
90
+ t: (key: string, defaultValue?: string) => string,
91
+ locationUuid?: string,
92
+ providerUuid?: string,
93
+ ): Promise<{ success: boolean; message: string }> {
94
+ try {
95
+ const result = await createPartographyEncounter(
96
+ patientUuid,
97
+ 'cervical-dilation',
98
+ formData,
99
+ locationUuid,
100
+ providerUuid,
101
+ );
102
+ return result;
103
+ } catch (error) {
104
+ return {
105
+ success: false,
106
+ message: error?.message || t('Failed to save cervical dilation data'),
107
+ };
108
+ }
109
+ }
@@ -0,0 +1,362 @@
1
+ import { openmrsFetch, restBaseUrl, toOmrsIsoString } from '@openmrs/esm-framework';
2
+ import useSWR from 'swr';
3
+ import { useTranslation } from 'react-i18next';
4
+ export { MCH_PARTOGRAPHY_ENCOUNTER_UUID } from '../types';
5
+ import { CERVIX_FORM_CONCEPTS, MCH_PARTOGRAPHY_ENCOUNTER_UUID, DESCENT_OF_HEAD_OPTIONS } from '../types';
6
+ import { ENCOUNTER_ROLE } from '../../../config-schema';
7
+
8
+ export interface CervixFormData {
9
+ hour: string;
10
+ time: string;
11
+ cervicalDilation: string;
12
+ descent: string;
13
+ }
14
+
15
+ export interface CervixObservation {
16
+ uuid: string;
17
+ concept: {
18
+ uuid: string;
19
+ display: string;
20
+ };
21
+ value: number | string;
22
+ obsDatetime: string;
23
+ display: string;
24
+ }
25
+
26
+ export interface CervixEncounter {
27
+ uuid: string;
28
+ encounterDatetime: string;
29
+ obs: CervixObservation[];
30
+ encounterType: {
31
+ uuid: string;
32
+ display: string;
33
+ };
34
+ patient: {
35
+ uuid: string;
36
+ };
37
+ }
38
+
39
+ export interface SaveCervixDataResponse {
40
+ success: boolean;
41
+ message: string;
42
+ encounter?: CervixEncounter;
43
+ error?: string;
44
+ }
45
+ export async function saveCervixFormData(
46
+ patientUuid: string,
47
+ formData: CervixFormData,
48
+ t: (key: string, defaultValue?: string, options?: any) => string,
49
+ providerUuid?: string,
50
+ locationUuid?: string,
51
+ ): Promise<SaveCervixDataResponse> {
52
+ try {
53
+ const observations = buildCervixObservations(formData);
54
+ const missingFields = [];
55
+ if (!formData.hour || isNaN(parseFloat(formData.hour))) {
56
+ missingFields.push(t('Hour'));
57
+ }
58
+ if (!formData.time || formData.time.trim() === '') {
59
+ missingFields.push(t('Time'));
60
+ }
61
+ if (!formData.cervicalDilation || isNaN(parseFloat(formData.cervicalDilation))) {
62
+ missingFields.push(t('Cervical Dilation'));
63
+ }
64
+ if (!formData.descent || isNaN(parseFloat(formData.descent))) {
65
+ missingFields.push(t('Descent of Head'));
66
+ }
67
+ if (missingFields.length > 0) {
68
+ return {
69
+ success: false,
70
+ message: t(`Missing or invalid fields: ${missingFields.join(', ')}`),
71
+ error: 'INVALID_FIELDS',
72
+ };
73
+ }
74
+ const session = await getCurrentSession();
75
+ const finalProviderUuid = providerUuid || session?.currentProvider?.uuid;
76
+ const finalLocationUuid = locationUuid || session?.sessionLocation?.uuid;
77
+ if (!finalProviderUuid) {
78
+ return { success: false, message: t('Provider information is required'), error: 'NO_PROVIDER' };
79
+ }
80
+ if (!finalLocationUuid) {
81
+ return { success: false, message: t('Location information is required'), error: 'NO_LOCATION' };
82
+ }
83
+ const now = new Date();
84
+ now.setMinutes(now.getMinutes() - 1);
85
+ const encounterPayload = {
86
+ patient: patientUuid,
87
+ encounterType: MCH_PARTOGRAPHY_ENCOUNTER_UUID,
88
+ location: finalLocationUuid,
89
+ encounterDatetime: toOmrsIsoString(now),
90
+ obs: observations,
91
+ encounterProviders: [
92
+ {
93
+ provider: finalProviderUuid,
94
+ encounterRole: ENCOUNTER_ROLE,
95
+ },
96
+ ],
97
+ };
98
+ const response = await openmrsFetch(`${restBaseUrl}/encounter`, {
99
+ method: 'POST',
100
+ headers: { 'Content-Type': 'application/json' },
101
+ body: JSON.stringify(encounterPayload),
102
+ });
103
+ if (!response.ok) {
104
+ const errorText = await response.text();
105
+ let errorMessage = t(`Failed to save cervix data: ${response.status} ${response.statusText}`);
106
+ try {
107
+ const errorData = JSON.parse(errorText);
108
+ if (errorData.error?.message) {
109
+ errorMessage = t(`Failed to save cervix data: ${errorData.error.message}`);
110
+ }
111
+ } catch (e) {}
112
+ return { success: false, message: errorMessage, error: 'SAVE_FAILED' };
113
+ }
114
+ const encounter = await response.json();
115
+ return { success: true, message: t('Cervix data saved successfully'), encounter };
116
+ } catch (error) {
117
+ return {
118
+ success: false,
119
+ message: t(`Failed to save cervix data: ${error instanceof Error ? error.message : t('Unknown error')}`),
120
+ error: 'EXCEPTION',
121
+ };
122
+ }
123
+ }
124
+
125
+ function buildCervixObservations(formData: CervixFormData): any[] {
126
+ const observations = [];
127
+ const obsDatetime = toOmrsIsoString(new Date());
128
+ if (formData.hour && formData.hour !== '') {
129
+ const hourValue = parseFloat(formData.hour);
130
+ if (!isNaN(hourValue)) {
131
+ observations.push({ concept: CERVIX_FORM_CONCEPTS.hour, value: hourValue, obsDatetime });
132
+ }
133
+ }
134
+ if (formData.time && formData.time !== '') {
135
+ observations.push({ concept: CERVIX_FORM_CONCEPTS.time, value: formData.time, obsDatetime });
136
+ }
137
+ if (formData.cervicalDilation && formData.cervicalDilation !== '') {
138
+ const dilationValue = parseFloat(formData.cervicalDilation);
139
+ if (!isNaN(dilationValue)) {
140
+ observations.push({ concept: CERVIX_FORM_CONCEPTS.cervicalDilation, value: dilationValue, obsDatetime });
141
+ }
142
+ }
143
+ if (formData.descent && formData.descent !== '') {
144
+ const descentValue = parseFloat(formData.descent);
145
+ if (!isNaN(descentValue)) {
146
+ // The descent of head is represented in the system as a coded concept
147
+ // where each station (1..5) maps to an answer concept UUID. The UI
148
+ // currently provides numeric station values (1..5), so convert that
149
+ // into the appropriate concept UUID before sending the obs. If we
150
+ // can't find a mapping, fall back to sending the numeric value.
151
+ const matchingOption = DESCENT_OF_HEAD_OPTIONS.find((opt) => opt.stationValue === descentValue);
152
+ if (matchingOption && matchingOption.value) {
153
+ observations.push({ concept: CERVIX_FORM_CONCEPTS.descentOfHead, value: matchingOption.value, obsDatetime });
154
+ } else {
155
+ observations.push({ concept: CERVIX_FORM_CONCEPTS.descentOfHead, value: descentValue, obsDatetime });
156
+ }
157
+ }
158
+ }
159
+ return observations;
160
+ }
161
+
162
+ async function getCurrentSession() {
163
+ try {
164
+ const response = await openmrsFetch(`${restBaseUrl}/session`);
165
+ if (response.ok) {
166
+ return await response.json();
167
+ }
168
+ } catch {
169
+ // Swallow error
170
+ }
171
+ return null;
172
+ }
173
+
174
+ export function useCervixData(patientUuid: string) {
175
+ // No comments, no logs
176
+ const encounterRepresentation =
177
+ 'custom:(uuid,encounterDatetime,encounterType:(uuid,display),' +
178
+ 'obs:(uuid,concept:(uuid,display),value,obsDatetime,display),' +
179
+ 'patient:(uuid))';
180
+ const url = `/ws/rest/v1/encounter?patient=${patientUuid}&encounterType=${MCH_PARTOGRAPHY_ENCOUNTER_UUID}&v=${encounterRepresentation}`;
181
+ const { data, error, isLoading, mutate } = useSWR<{ data: { results: CervixEncounter[] } }>(
182
+ patientUuid ? url : null,
183
+ openmrsFetch,
184
+ );
185
+ const cervixData = data?.data?.results || [];
186
+ const sortedEncounters = cervixData.sort(
187
+ (a, b) => new Date(b.encounterDatetime).getTime() - new Date(a.encounterDatetime).getTime(),
188
+ );
189
+ const transformedData = sortedEncounters.map((encounter) => {
190
+ const observations = encounter.obs || [];
191
+
192
+ // Helper: try exact concept match first
193
+ const byConcept = (uuid: string) => observations.find((obs) => obs.concept?.uuid === uuid);
194
+
195
+ let hourObs = byConcept(CERVIX_FORM_CONCEPTS.hour);
196
+ let timeObs = byConcept(CERVIX_FORM_CONCEPTS.time);
197
+ let cervicalDilationObs = byConcept(CERVIX_FORM_CONCEPTS.cervicalDilation);
198
+ let descentObs = byConcept(CERVIX_FORM_CONCEPTS.descentOfHead);
199
+
200
+ // If any primary obs were not found, attempt heuristics based on value shapes
201
+ const unused = new Set(observations);
202
+ if (!hourObs) {
203
+ hourObs = observations.find((o) => {
204
+ const v = o.value;
205
+ if (typeof v === 'number') {
206
+ return v >= 0 && v <= 23;
207
+ }
208
+ if (typeof v === 'string' && /^\d{1,2}$/.test(v)) {
209
+ return Number(v) >= 0 && Number(v) <= 23;
210
+ }
211
+ return false;
212
+ });
213
+ }
214
+
215
+ if (!timeObs) {
216
+ timeObs = observations.find((o) => typeof o.value === 'string' && /^\d{1,2}:\d{2}$/.test(o.value));
217
+ }
218
+
219
+ if (!cervicalDilationObs) {
220
+ cervicalDilationObs = observations.find((o) => {
221
+ const v = o.value;
222
+ if (typeof v === 'number') {
223
+ return v >= 0 && v <= 10;
224
+ }
225
+ if (typeof v === 'string' && !/^\d{1,2}:\d{2}$/.test(v)) {
226
+ const parsed = Number(v);
227
+ return !isNaN(parsed) && parsed >= 0 && parsed <= 10;
228
+ }
229
+ return false;
230
+ });
231
+ }
232
+
233
+ if (!descentObs) {
234
+ // descent might be numeric 1-5, a numeric string, or a coded answer (uuid string)
235
+ descentObs = observations.find((o) => {
236
+ const v = o.value;
237
+ if (typeof v === 'number') {
238
+ return v >= 1 && v <= 5;
239
+ }
240
+ if (typeof v === 'string') {
241
+ if (/^\d{1,2}$/.test(v)) {
242
+ return Number(v) >= 1 && Number(v) <= 5;
243
+ }
244
+ // likely a UUID (coded answer)
245
+ if (/^[0-9a-fA-F-]{8,}$/.test(v)) {
246
+ return true;
247
+ }
248
+ }
249
+ if (typeof v === 'object' && v !== null && ((v as any).uuid || (v as any).display)) {
250
+ return true;
251
+ }
252
+ return false;
253
+ });
254
+ }
255
+
256
+ // Normalize descentOfHead to numeric station where possible
257
+ let descentOfHead = null;
258
+ if (descentObs?.value !== undefined && descentObs?.value !== null) {
259
+ const val = descentObs.value;
260
+ if (typeof val === 'number') {
261
+ descentOfHead = val;
262
+ } else if (typeof val === 'string') {
263
+ // If the server returned a coded answer (concept UUID) for descent
264
+ // map it back to the numeric station value using DESCENT_OF_HEAD_OPTIONS.
265
+ const match = DESCENT_OF_HEAD_OPTIONS.find((opt) => opt.value === val || opt.conceptUuid === val);
266
+ if (match && typeof match.stationValue === 'number') {
267
+ descentOfHead = match.stationValue;
268
+ } else {
269
+ // Fallback: try parsing numeric string
270
+ const parsed = Number(val);
271
+ descentOfHead = isNaN(parsed) ? null : parsed;
272
+ }
273
+ } else if (typeof val === 'object' && val !== null) {
274
+ const uuid = (val as any).uuid || (val as any).value || null;
275
+ if (uuid) {
276
+ const match = DESCENT_OF_HEAD_OPTIONS.find((opt) => opt.value === uuid || opt.conceptUuid === uuid);
277
+ if (match && typeof match.stationValue === 'number') {
278
+ descentOfHead = match.stationValue;
279
+ }
280
+ }
281
+ }
282
+ }
283
+
284
+ // Also compute a human-friendly label for descent (when server returns
285
+ // a coded answer UUID we map it to the option's text). Keep numeric
286
+ // `descentOfHead` for charting but expose `descentLabel` for tables/UI.
287
+ let descentLabel: string | null = null;
288
+ if (descentObs && descentObs.value !== undefined && descentObs.value !== null) {
289
+ const val = descentObs.value;
290
+ if (typeof val === 'string') {
291
+ const match = DESCENT_OF_HEAD_OPTIONS.find((opt) => opt.value === val || opt.conceptUuid === val);
292
+ if (match) {
293
+ descentLabel = match.text;
294
+ }
295
+ }
296
+ // if no label found and we have a numeric station, use that
297
+ if (!descentLabel && typeof descentOfHead === 'number') {
298
+ descentLabel = String(descentOfHead);
299
+ }
300
+ }
301
+
302
+ return {
303
+ uuid: encounter.uuid,
304
+ encounterDatetime: encounter.encounterDatetime,
305
+ hour: hourObs?.value !== undefined && hourObs?.value !== null ? Number(hourObs.value) : null,
306
+ time: timeObs?.value !== undefined && timeObs?.value !== null ? String(timeObs.value) : null,
307
+ cervicalDilation:
308
+ cervicalDilationObs?.value !== undefined && cervicalDilationObs?.value !== null
309
+ ? Number(cervicalDilationObs.value)
310
+ : null,
311
+ descentOfHead,
312
+ descentLabel,
313
+ timeDisplay: timeObs?.value ? `${hourObs?.value || '??'}:${timeObs.value}` : null,
314
+ };
315
+ });
316
+ const existingTimeEntries = transformedData
317
+ .filter((data) => data.hour !== null && data.time !== null)
318
+ .map((data) => ({ hour: data.hour!, time: data.time! }));
319
+ const existingCervixData = transformedData
320
+ .filter((data) => data.cervicalDilation !== null && data.descentOfHead !== null)
321
+ .map((data) => ({ cervicalDilation: data.cervicalDilation!, descentOfHead: data.descentOfHead! }));
322
+ const selectedHours = existingTimeEntries.map((entry) => entry.hour);
323
+ return {
324
+ encounters: sortedEncounters,
325
+ cervixData: transformedData,
326
+ existingTimeEntries,
327
+ existingCervixData,
328
+ selectedHours,
329
+ isLoading,
330
+ error,
331
+ mutate,
332
+ };
333
+ }
334
+
335
+ export async function deleteCervixEncounter(encounterUuid: string): Promise<SaveCervixDataResponse> {
336
+ const { t } = require('react-i18next');
337
+ try {
338
+ const response = await openmrsFetch(`${restBaseUrl}/encounter/${encounterUuid}`, {
339
+ method: 'DELETE',
340
+ headers: { 'Content-Type': 'application/json' },
341
+ });
342
+ if (!response.ok) {
343
+ return {
344
+ success: false,
345
+ message: t('Failed to delete encounter: {{status}} {{statusText}}', {
346
+ status: response.status,
347
+ statusText: response.statusText,
348
+ }),
349
+ error: 'DELETE_FAILED',
350
+ };
351
+ }
352
+ return { success: true, message: t('Encounter deleted successfully') };
353
+ } catch (error) {
354
+ return {
355
+ success: false,
356
+ message: t('Failed to delete encounter: {{message}}', {
357
+ message: error instanceof Error ? error.message : t('Unknown error'),
358
+ }),
359
+ error: 'EXCEPTION',
360
+ };
361
+ }
362
+ }
@@ -0,0 +1,101 @@
1
+ import React from 'react';
2
+ import { openmrsFetch, restBaseUrl, toOmrsIsoString } from '@openmrs/esm-framework';
3
+ import useSWR from 'swr';
4
+ import { MCH_PARTOGRAPHY_ENCOUNTER_UUID, PARTOGRAPHY_CONCEPTS } from '../types';
5
+ import { createPartographyEncounter } from '../partography.resource';
6
+ import { useTranslation } from 'react-i18next';
7
+
8
+ export interface DescentOfHeadEntry {
9
+ id: string;
10
+ uuid: string;
11
+ encounterUuid: string;
12
+ descentOfHead: number;
13
+ time?: string;
14
+ date: string;
15
+ encounterDatetime: string;
16
+ obsDatetime: string;
17
+ }
18
+
19
+ export function useDescentOfHeadData(patientUuid: string) {
20
+ const { t } = useTranslation();
21
+ const fetcher = (url: string) => openmrsFetch(url).then((res) => res.json());
22
+ const { data, error, isLoading, mutate } = useSWR(
23
+ patientUuid
24
+ ? `${restBaseUrl}/encounter?patient=${patientUuid}&encounterType=${MCH_PARTOGRAPHY_ENCOUNTER_UUID}&v=full&limit=100&order=desc`
25
+ : null,
26
+ fetcher,
27
+ );
28
+ const descentOfHeadData: DescentOfHeadEntry[] = React.useMemo(() => {
29
+ if (!data?.results || !Array.isArray(data.results)) {
30
+ return [];
31
+ }
32
+ const entries: DescentOfHeadEntry[] = [];
33
+ for (const encounter of data.results) {
34
+ if (!encounter.obs || !Array.isArray(encounter.obs)) {
35
+ continue;
36
+ }
37
+ const descentObs = encounter.obs.find((obs) => obs.concept.uuid === PARTOGRAPHY_CONCEPTS['descent-of-head']);
38
+ if (descentObs) {
39
+ const encounterDatetime = new Date(encounter.encounterDatetime);
40
+ const timeObs = encounter.obs.find(
41
+ (obs) =>
42
+ obs.concept.uuid === PARTOGRAPHY_CONCEPTS['fetal-heart-rate-time'] &&
43
+ typeof obs.value === 'string' &&
44
+ obs.value.startsWith('Time:'),
45
+ );
46
+ let time = '';
47
+ if (timeObs && typeof timeObs.value === 'string') {
48
+ const timeMatch = timeObs.value.match(/Time:\s*(.+)/);
49
+ if (timeMatch) {
50
+ time = timeMatch[1].trim();
51
+ }
52
+ }
53
+ entries.push({
54
+ id: `doh-${encounter.uuid}`,
55
+ uuid: encounter.uuid,
56
+ encounterUuid: encounter.uuid,
57
+ descentOfHead: parseFloat(descentObs.value) || 0,
58
+ time,
59
+ date: encounterDatetime.toLocaleDateString(),
60
+ encounterDatetime: encounterDatetime.toISOString(),
61
+ obsDatetime: descentObs.obsDatetime,
62
+ });
63
+ }
64
+ }
65
+ return entries.sort((a, b) => new Date(b.encounterDatetime).getTime() - new Date(a.encounterDatetime).getTime());
66
+ }, [data]);
67
+ let localizedError = error;
68
+ if (error) {
69
+ localizedError = t('Failed to load descent of head data');
70
+ }
71
+ return {
72
+ descentOfHeadData,
73
+ isLoading,
74
+ error: localizedError,
75
+ mutate,
76
+ };
77
+ }
78
+
79
+ export async function saveDescentOfHeadData(
80
+ patientUuid: string,
81
+ formData: { descentOfHead: number; time?: string },
82
+ t: (key: string, defaultValue?: string) => string,
83
+ locationUuid?: string,
84
+ providerUuid?: string,
85
+ ): Promise<{ success: boolean; message: string }> {
86
+ try {
87
+ const result = await createPartographyEncounter(
88
+ patientUuid,
89
+ 'descent-of-head',
90
+ formData,
91
+ locationUuid,
92
+ providerUuid,
93
+ );
94
+ return result;
95
+ } catch (error) {
96
+ return {
97
+ success: false,
98
+ message: error?.message || t('Failed to save descent of head data'),
99
+ };
100
+ }
101
+ }