@ampath/esm-dha-workflow-app 4.0.0-next.340 → 4.0.0-next.344

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 (128) hide show
  1. package/dist/1149.js +1 -1
  2. package/dist/1171.js +1 -0
  3. package/dist/1171.js.map +1 -0
  4. package/dist/1177.js +1 -0
  5. package/dist/1177.js.map +1 -0
  6. package/dist/1239.js +1 -0
  7. package/dist/1239.js.map +1 -0
  8. package/dist/1551.js +1 -1
  9. package/dist/1560.js +1 -0
  10. package/dist/1560.js.map +1 -0
  11. package/dist/1928.js +1 -0
  12. package/dist/1928.js.map +1 -0
  13. package/dist/2093.js +1 -1
  14. package/dist/2230.js +1 -1
  15. package/dist/2493.js +1 -1
  16. package/dist/2914.js +1 -1
  17. package/dist/3078.js +1 -0
  18. package/dist/3078.js.map +1 -0
  19. package/dist/327.js +1 -0
  20. package/dist/327.js.map +1 -0
  21. package/dist/4605.js +1 -1
  22. package/dist/4611.js +1 -1
  23. package/dist/4674.js +1 -1
  24. package/dist/4674.js.map +1 -1
  25. package/dist/4986.js +1 -1
  26. package/dist/5167.js +1 -1
  27. package/dist/5317.js +1 -0
  28. package/dist/5317.js.map +1 -0
  29. package/dist/5344.js +1 -0
  30. package/dist/{2936.js.map → 5344.js.map} +1 -1
  31. package/dist/5765.js +1 -0
  32. package/dist/5765.js.map +1 -0
  33. package/dist/5851.js +1 -1
  34. package/dist/5851.js.map +1 -1
  35. package/dist/5961.js +1 -1
  36. package/dist/6264.js +1 -1
  37. package/dist/6264.js.map +1 -1
  38. package/dist/6720.js +1 -1
  39. package/dist/6942.js +1 -1
  40. package/dist/6942.js.map +1 -1
  41. package/dist/7521.js +1 -1
  42. package/dist/7521.js.map +1 -1
  43. package/dist/7549.js +1 -0
  44. package/dist/7549.js.map +1 -0
  45. package/dist/7717.js +1 -1
  46. package/dist/7857.js +1 -0
  47. package/dist/7857.js.map +1 -0
  48. package/dist/850.js +1 -0
  49. package/dist/850.js.map +1 -0
  50. package/dist/8733.js +1 -1
  51. package/dist/9070.js +1 -1
  52. package/dist/9093.js +1 -1
  53. package/dist/9093.js.map +1 -1
  54. package/dist/9338.js +1 -1
  55. package/dist/9338.js.map +1 -1
  56. package/dist/9349.js +1 -0
  57. package/dist/9349.js.map +1 -0
  58. package/dist/9428.js +1 -1
  59. package/dist/9428.js.map +1 -1
  60. package/dist/9789.js +1 -0
  61. package/dist/9789.js.map +1 -0
  62. package/dist/9871.js +1 -0
  63. package/dist/9871.js.map +1 -0
  64. package/dist/esm-dha-workflow-app.js +1 -1
  65. package/dist/esm-dha-workflow-app.js.buildmanifest.json +362 -239
  66. package/dist/esm-dha-workflow-app.js.map +1 -1
  67. package/dist/main.js +1 -1
  68. package/dist/main.js.map +1 -1
  69. package/dist/routes.json +1 -1
  70. package/package.json +1 -1
  71. package/src/billing/billing-claims.resource.ts +8 -7
  72. package/src/billing/dashboard/v2/clearance/cash.component.tsx +9 -9
  73. package/src/billing/dashboard/v2/clearance/pay-cash.component.tsx +24 -22
  74. package/src/billing/dashboard/v2/facility-bills/facility-bills.component.tsx +5 -4
  75. package/src/billing/dashboard/v2/patient-bill-details/attachments/final-bill.component.tsx +2 -2
  76. package/src/billing/dashboard/v2/patient-bill-details/attachments/generate-attachments.component.tsx +15 -4
  77. package/src/billing/dashboard/v2/patient-bill-details/attachments/invoice.component.tsx +2 -2
  78. package/src/billing/dashboard/v2/types/index.ts +6 -0
  79. package/src/billing/dashboard/v3/claim-visits/claim-intervention-details/claim-intervention-details.component.tsx +6 -1
  80. package/src/billing/dashboard/v3/claim-visits/claim-visit-details/claim-visit-details.component.tsx +5 -8
  81. package/src/billing/dashboard/v3/facility-bills/facility-bills.component.tsx +65 -62
  82. package/src/billing/dashboard/v3/patient-bill-details/attachments/case-summary/case-summary.tsx +30 -16
  83. package/src/billing/dashboard/v3/patient-bill-details/attachments/generate-attachments.component.tsx +3 -1
  84. package/src/billing/dashboard/v3/patient-bill-details/bill-details/bill-details.scss +4 -0
  85. package/src/billing/dashboard/v3/patient-bill-details/bill-details/bill-details.tsx +154 -156
  86. package/src/billing/dashboard/v3/patient-bill-details/claim-details/patient-claim-details.component.tsx +22 -23
  87. package/src/billing/dashboard/v3/patient-bill-details/patient-bill-details.tsx +5 -4
  88. package/src/dashboard/dashboard.component.tsx +3 -2
  89. package/src/dashboard/overview/overview.component.tsx +3 -1
  90. package/src/dashboard-meta/emt-dashboard.meta.ts +6 -0
  91. package/src/emt/cr-lookup.resource.test.ts +88 -0
  92. package/src/emt/cr-lookup.resource.ts +42 -0
  93. package/src/emt/emt-queue.component.test.tsx +324 -0
  94. package/src/emt/emt-queue.component.tsx +449 -0
  95. package/src/emt/emt-queue.scss +83 -0
  96. package/src/emt/emt.resource.test.ts +247 -0
  97. package/src/emt/emt.resource.ts +150 -0
  98. package/src/emt/handover-modal/handover-modal.component.test.tsx +273 -0
  99. package/src/emt/handover-modal/handover-modal.component.tsx +524 -0
  100. package/src/emt/handover-modal/handover-modal.scss +88 -0
  101. package/src/emt/types/emt.types.ts +137 -0
  102. package/src/index.ts +10 -0
  103. package/src/registry/registry.component.tsx +49 -1
  104. package/src/registry/utils/hie-client-adapter.ts +1 -1
  105. package/src/root.component.tsx +2 -0
  106. package/src/routes.json +25 -0
  107. package/src/side-nav-menu/nav-link-config.ts +4 -0
  108. package/dist/1362.js +0 -1
  109. package/dist/1362.js.map +0 -1
  110. package/dist/203.js +0 -1
  111. package/dist/203.js.map +0 -1
  112. package/dist/2200.js +0 -1
  113. package/dist/2200.js.map +0 -1
  114. package/dist/2936.js +0 -1
  115. package/dist/2988.js +0 -1
  116. package/dist/2988.js.map +0 -1
  117. package/dist/307.js +0 -1
  118. package/dist/307.js.map +0 -1
  119. package/dist/4008.js +0 -1
  120. package/dist/4008.js.map +0 -1
  121. package/dist/446.js +0 -1
  122. package/dist/446.js.map +0 -1
  123. package/dist/5097.js +0 -1
  124. package/dist/5097.js.map +0 -1
  125. package/dist/7240.js +0 -1
  126. package/dist/7240.js.map +0 -1
  127. package/dist/874.js +0 -1
  128. package/dist/874.js.map +0 -1
@@ -0,0 +1,524 @@
1
+ import React, { useEffect, useMemo, useRef, useState } from 'react';
2
+ import { TextInput, Button, ButtonSet, InlineLoading, Tag, ComboBox, Dropdown } from '@carbon/react';
3
+ import { showSnackbar, type DefaultWorkspaceProps } from '@openmrs/esm-framework';
4
+ import OTPInput from '../../shared/ui/otp-input/otp-input.component';
5
+ import {
6
+ initiateHandover,
7
+ verifyHandoverOtp,
8
+ getHandoverRequestId,
9
+ } from '../emt.resource';
10
+ import { EmtApiError, type EmtReferralRow, type ReceivingDoctor } from '../types/emt.types';
11
+ import {
12
+ searchOpenMrsProviders,
13
+ searchHealthWorkerRegistry,
14
+ type OpenMrsProviderHit,
15
+ type HwrSearchResult,
16
+ } from '../../billing/dashboard/v3/preauth/preauth.resource';
17
+ import styles from './handover-modal.scss';
18
+
19
+ type Step = 'doctor' | 'confirm' | 'otp';
20
+
21
+ /** Identifier type understood by the Health Worker Registry practitioner search. */
22
+ const HWR_IDENTIFIER_TYPE = 'National ID';
23
+
24
+ const REGULATION_BODIES = ['KMPDC', 'COC', 'NCK'] as const;
25
+ type RegulationBody = (typeof REGULATION_BODIES)[number];
26
+
27
+ /** Map a raw HWR `licensing_body` string onto one of the three regulators the EMT API accepts. */
28
+ const normalizeRegulationBody = (value?: string | null): RegulationBody => {
29
+ if (!value) return 'KMPDC';
30
+ const raw = value.trim();
31
+ const exact = REGULATION_BODIES.find((b) => b.toLowerCase() === raw.toLowerCase());
32
+ if (exact) return exact;
33
+ const upper = raw.toUpperCase();
34
+ if (upper.includes('KMPDC') || upper.includes('MEDICAL PRACTITIONER') || upper.includes('DOCTOR')) return 'KMPDC';
35
+ if (upper.includes('CLINICAL OFFICER') || upper === 'COC' || upper.includes('CLINICAL OFFICERS COUNCIL')) return 'COC';
36
+ if (upper.includes('NURS') || upper === 'NCK') return 'NCK';
37
+ return 'KMPDC';
38
+ };
39
+
40
+ /** Registration name, shared with whoever launches this workspace. */
41
+ export const EMT_HANDOVER_WORKSPACE = 'emt-handover-workspace';
42
+
43
+ interface HandoverWorkspaceProps extends Partial<DefaultWorkspaceProps> {
44
+ referral: EmtReferralRow;
45
+ locationUuid: string;
46
+ /** Fired after a successful verify — caller refreshes the queue + launches the visit. */
47
+ onHandoverComplete: (referral: EmtReferralRow) => void;
48
+ /** Fired when the upstream 404s — the referral is gone (handled elsewhere); caller drops it. */
49
+ onReferralUnavailable: (referral: EmtReferralRow, reason: string) => void;
50
+ }
51
+
52
+ /**
53
+ * OTP-verified handover flow for an EMT referral:
54
+ *
55
+ * doctor (resolve the receiving clinician's regulatory identifier)
56
+ * → confirm (shows patient/case/receiving doctor)
57
+ * → initiate (POST /handover/initiate) — "Sending OTP to doctor…"
58
+ * → OTP entry
59
+ * → verify (POST /handover/verify)
60
+ * → onHandoverComplete (queue refresh + visit launch)
61
+ *
62
+ * The doctor step reuses the preauth mechanism: search OpenMRS providers by name,
63
+ * auto-pull the provider's National ID attribute, then look that National ID up in
64
+ * the Health Worker Registry to obtain the registration number + licensing body.
65
+ *
66
+ * Each upstream failure mode gets a distinct, user-readable message. An
67
+ * invalid/expired OTP keeps the modal open so the user can retry or re-initiate.
68
+ * A 404 means the referral no longer exists, so the parent is told to drop it
69
+ * rather than leaving the user stuck on a step that can never succeed.
70
+ */
71
+ const HandoverModal: React.FC<HandoverWorkspaceProps> = ({
72
+ referral,
73
+ locationUuid,
74
+ closeWorkspace,
75
+ promptBeforeClosing,
76
+ onHandoverComplete,
77
+ onReferralUnavailable,
78
+ }) => {
79
+ const [step, setStep] = useState<Step>('doctor');
80
+ const [otp, setOtp] = useState('');
81
+ const [loading, setLoading] = useState(false);
82
+ const [error, setError] = useState<string>('');
83
+ const [handoverRequestId, setHandoverRequestId] = useState('');
84
+
85
+ // --- receiving doctor resolution (doctor step) ---
86
+ const [providerQuery, setProviderQuery] = useState('');
87
+ const [providerHits, setProviderHits] = useState<OpenMrsProviderHit[]>([]);
88
+ const [searchingProviders, setSearchingProviders] = useState(false);
89
+ const [selectedProvider, setSelectedProvider] = useState<OpenMrsProviderHit | null>(null);
90
+ const providerSearchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
91
+ const [doctorNationalId, setDoctorNationalId] = useState('');
92
+ const [searchingHwr, setSearchingHwr] = useState(false);
93
+ const [hwrHit, setHwrHit] = useState<HwrSearchResult | null>(null);
94
+ const [regulationBody, setRegulationBody] = useState<RegulationBody>('KMPDC');
95
+ const [doctorSearchError, setDoctorSearchError] = useState('');
96
+
97
+ useEffect(
98
+ () => () => {
99
+ if (providerSearchTimer.current) {
100
+ clearTimeout(providerSearchTimer.current);
101
+ }
102
+ },
103
+ [],
104
+ );
105
+
106
+ // Past the doctor step an OTP may already be in flight — warn before losing that progress.
107
+ useEffect(() => {
108
+ promptBeforeClosing?.(() => step !== 'doctor');
109
+ }, [step, promptBeforeClosing]);
110
+
111
+ /** Only a HWR hit carrying a registration number yields a usable receiving doctor. */
112
+ const resolvedDoctor: ReceivingDoctor | null = useMemo(() => {
113
+ const regId = hwrHit?.membership?.registration_id?.trim();
114
+ if (!regId) return null;
115
+ return {
116
+ name: hwrHit?.membership?.full_name || selectedProvider?.display || 'Receiving clinician',
117
+ identifier: regId,
118
+ identifier_type: 'registration_number',
119
+ regulator: regulationBody,
120
+ };
121
+ }, [hwrHit, selectedProvider, regulationBody]);
122
+
123
+ const reset = () => {
124
+ if (providerSearchTimer.current) {
125
+ clearTimeout(providerSearchTimer.current);
126
+ providerSearchTimer.current = null;
127
+ }
128
+ setStep('doctor');
129
+ setOtp('');
130
+ setLoading(false);
131
+ setError('');
132
+ setHandoverRequestId('');
133
+ setProviderQuery('');
134
+ setProviderHits([]);
135
+ setSearchingProviders(false);
136
+ setSelectedProvider(null);
137
+ setDoctorNationalId('');
138
+ setSearchingHwr(false);
139
+ setHwrHit(null);
140
+ setRegulationBody('KMPDC');
141
+ setDoctorSearchError('');
142
+ };
143
+
144
+ const handleClose = () => {
145
+ reset();
146
+ closeWorkspace?.();
147
+ };
148
+
149
+ const showAlert = (kind: 'success' | 'error' | 'info' | 'warning', title: string, subtitle = '') =>
150
+ showSnackbar({ kind, title, subtitle });
151
+
152
+ // Map an EMT error to a human-readable message, branchable by status.
153
+ const describeError = (err: unknown, fallback: string): string => {
154
+ if (err instanceof EmtApiError) {
155
+ const { status } = err;
156
+ if (status === 400 || status === 422) return err.message || 'Some details were invalid. Please check and retry.';
157
+ if (status === 401 || status === 403) return 'Your session may have expired. Please re-authenticate and retry.';
158
+ if (status === 404) return 'This referral can no longer be found — it may have been handled elsewhere.';
159
+ if (status === 409) return 'A handover has already been initiated or completed for this case.';
160
+ if (status === 410) return 'This OTP has expired. Please re-initiate the handover.';
161
+ if (status >= 500) return 'The EMT service is temporarily unavailable. Please retry shortly.';
162
+ }
163
+ return err instanceof Error ? err.message || fallback : fallback;
164
+ };
165
+
166
+ // ---------------------------------------------------------------- doctor step
167
+
168
+ const providerItemLabel = (item: OpenMrsProviderHit | null) =>
169
+ item ? (item.nationalId ? `${item.display} · ${item.nationalId}` : item.display) : '';
170
+
171
+ const handleSearchHwr = async (idOverride?: string) => {
172
+ const idValue = (idOverride ?? doctorNationalId).trim();
173
+ if (!idValue) {
174
+ setDoctorSearchError('Enter the doctor National ID to look up the Health Worker Registry.');
175
+ return;
176
+ }
177
+ setSearchingHwr(true);
178
+ setDoctorSearchError('');
179
+ try {
180
+ const results = await searchHealthWorkerRegistry({
181
+ identifierType: HWR_IDENTIFIER_TYPE,
182
+ identifierValue: idValue,
183
+ locationUuid,
184
+ });
185
+ const hit = results[0];
186
+ if (!hit) {
187
+ setHwrHit(null);
188
+ setDoctorSearchError('No health worker record found for this National ID.');
189
+ showAlert('warning', 'No health worker found', `National ID ${idValue} is not in the registry.`);
190
+ return;
191
+ }
192
+ // Keep the hit either way so the matched name is visible, but only a
193
+ // registration number makes the doctor usable for the handover.
194
+ setHwrHit(hit);
195
+ if (!hit.membership?.registration_id?.trim()) {
196
+ setDoctorSearchError('This health worker has no registration number on file — cannot proceed.');
197
+ return;
198
+ }
199
+ const body = normalizeRegulationBody(hit.membership?.licensing_body);
200
+ setRegulationBody(body);
201
+ setDoctorSearchError('');
202
+ showAlert(
203
+ 'success',
204
+ 'Receiving doctor resolved',
205
+ `${hit.membership?.full_name || 'Match'} · ${body} ${hit.membership.registration_id}`,
206
+ );
207
+ } catch (err) {
208
+ setHwrHit(null);
209
+ const message = describeError(err, 'Health Worker Registry search failed.');
210
+ setDoctorSearchError(message);
211
+ showAlert('error', 'HWR search failed', message);
212
+ } finally {
213
+ setSearchingHwr(false);
214
+ }
215
+ };
216
+
217
+ const applyProviderSelection = (hit: OpenMrsProviderHit | null) => {
218
+ setSelectedProvider(hit);
219
+ if (!hit) return;
220
+ if (hit.nationalId) {
221
+ setDoctorNationalId(hit.nationalId);
222
+ setDoctorSearchError('');
223
+ void handleSearchHwr(hit.nationalId);
224
+ } else {
225
+ setDoctorNationalId('');
226
+ setHwrHit(null);
227
+ setDoctorSearchError('No National ID on file for this provider — enter it manually and search HWR.');
228
+ }
229
+ };
230
+
231
+ const handleProviderInputChange = (inputValue: string) => {
232
+ setProviderQuery(inputValue);
233
+ const selectedLabel = providerItemLabel(selectedProvider);
234
+ if (inputValue === selectedLabel) {
235
+ return;
236
+ }
237
+ if (selectedProvider) {
238
+ setSelectedProvider(null);
239
+ }
240
+ if (providerSearchTimer.current) {
241
+ clearTimeout(providerSearchTimer.current);
242
+ }
243
+ if (!inputValue || inputValue.trim().length < 2) {
244
+ setProviderHits([]);
245
+ setSearchingProviders(false);
246
+ return;
247
+ }
248
+ providerSearchTimer.current = setTimeout(async () => {
249
+ setSearchingProviders(true);
250
+ try {
251
+ const hits = await searchOpenMrsProviders(inputValue);
252
+ setProviderHits(hits);
253
+ } catch (err) {
254
+ setProviderHits([]);
255
+ const message = describeError(err, 'Provider search failed.');
256
+ setDoctorSearchError(message);
257
+ showAlert('error', 'Provider search failed', message);
258
+ } finally {
259
+ setSearchingProviders(false);
260
+ }
261
+ }, 300);
262
+ };
263
+
264
+ // ------------------------------------------------------------- handover steps
265
+
266
+ const handleInitiate = async () => {
267
+ if (!resolvedDoctor) {
268
+ // Continue is gated on this, so this is a defensive fallback only.
269
+ setStep('doctor');
270
+ setDoctorSearchError('Resolve the receiving doctor before sending the OTP.');
271
+ return;
272
+ }
273
+ setLoading(true);
274
+ setError('');
275
+ try {
276
+ const res = await initiateHandover({
277
+ incidenceNumber: referral.case_number,
278
+ identifier: resolvedDoctor.identifier,
279
+ identifier_type: resolvedDoctor.identifier_type,
280
+ regulator: resolvedDoctor.regulator,
281
+ locationUuid,
282
+ });
283
+ const newRequestId = getHandoverRequestId(res);
284
+ if (!newRequestId) {
285
+ // The response shape didn't carry a request id — surface rather than guess.
286
+ throw new EmtApiError(
287
+ 0,
288
+ 'Handover was initiated but no request id was returned. Please retry or contact support.',
289
+ );
290
+ }
291
+ setHandoverRequestId(newRequestId);
292
+ setStep('otp');
293
+ showAlert('info', 'OTP sent', `A code was sent to ${resolvedDoctor.name}.`);
294
+ } catch (err) {
295
+ const message = describeError(err, 'Failed to initiate handover.');
296
+ if (err instanceof EmtApiError && err.status === 404) {
297
+ // The referral is gone — the workspace closes and the caller drops the row.
298
+ onReferralUnavailable(referral, message);
299
+ closeWorkspace?.();
300
+ return;
301
+ }
302
+ setError(message);
303
+ showAlert('error', 'Handover failed', message);
304
+ } finally {
305
+ setLoading(false);
306
+ }
307
+ };
308
+
309
+ const handleVerify = async () => {
310
+ setLoading(true);
311
+ setError('');
312
+ try {
313
+ if (!handoverRequestId) {
314
+ // Lost the request id (e.g. workspace reopened) — go back to initiate.
315
+ setStep('confirm');
316
+ setError('The handover session was lost. Please re-initiate.');
317
+ return;
318
+ }
319
+ await verifyHandoverOtp({
320
+ incidenceNumber: referral.case_number,
321
+ request_id: handoverRequestId,
322
+ otp,
323
+ locationUuid,
324
+ });
325
+ showAlert(
326
+ 'success',
327
+ 'Handover complete',
328
+ `${referral.patientName} (${referral.case_number}) has been handed over.`,
329
+ );
330
+ reset();
331
+ onHandoverComplete(referral);
332
+ closeWorkspace?.();
333
+ } catch (err) {
334
+ const message = describeError(err, 'OTP verification failed.');
335
+ if (err instanceof EmtApiError && err.status === 404) {
336
+ // The referral is gone — the workspace closes and the caller drops the row.
337
+ onReferralUnavailable(referral, message);
338
+ closeWorkspace?.();
339
+ return;
340
+ }
341
+ setError(message);
342
+ showAlert('error', 'Verification failed', message);
343
+ // Stay on the OTP step so the user can retry / re-initiate.
344
+ } finally {
345
+ setLoading(false);
346
+ }
347
+ };
348
+
349
+ const handleResend = async () => {
350
+ // Re-initiate (re-sends the OTP). The backend exposes no dedicated resend,
351
+ // so we call initiate again as the resend affordance.
352
+ setStep('confirm');
353
+ setOtp('');
354
+ setError('');
355
+ await handleInitiate();
356
+ };
357
+
358
+ const patientLine = referral.patientName || referral.cr_id;
359
+
360
+ const modalHeading =
361
+ step === 'doctor' ? 'Select receiving doctor' : step === 'confirm' ? 'Confirm handover' : 'Enter doctor OTP';
362
+
363
+ return (
364
+ <div className={styles.container}>
365
+ <h4 className={styles.heading}>{modalHeading}</h4>
366
+ <div className={styles.body}>
367
+ {step === 'doctor' && (
368
+ <div className={styles.doctorBody}>
369
+ <p className={styles.confirmText}>
370
+ Search for the receiving clinician to resolve their regulatory registration number. The
371
+ handover OTP is sent to this doctor.
372
+ </p>
373
+
374
+ <div className={styles.searchBlock}>
375
+ <ComboBox
376
+ id="emt-handover-provider"
377
+ titleText="Provider"
378
+ placeholder="Type at least 2 characters to search"
379
+ items={providerHits}
380
+ itemToString={providerItemLabel}
381
+ selectedItem={selectedProvider}
382
+ shouldFilterItem={() => true}
383
+ onInputChange={handleProviderInputChange}
384
+ onChange={({ selectedItem }) => applyProviderSelection(selectedItem as OpenMrsProviderHit | null)}
385
+ />
386
+ {searchingProviders ? <InlineLoading description="Searching providers…" /> : null}
387
+ {!searchingProviders && !selectedProvider && providerQuery.trim().length >= 2 && !providerHits.length ? (
388
+ <p className={styles.fieldHint}>No providers matched “{providerQuery.trim()}”.</p>
389
+ ) : null}
390
+ </div>
391
+
392
+ <div className={styles.row}>
393
+ <TextInput
394
+ id="emt-handover-doctor-national-id"
395
+ labelText="Doctor National ID"
396
+ value={doctorNationalId}
397
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
398
+ setDoctorNationalId(e.target.value);
399
+ setHwrHit(null);
400
+ }}
401
+ />
402
+ <Button
403
+ kind="tertiary"
404
+ size="md"
405
+ onClick={() => handleSearchHwr()}
406
+ disabled={searchingHwr || !doctorNationalId.trim()}
407
+ >
408
+ {searchingHwr ? 'Searching HWR…' : 'Search HWR'}
409
+ </Button>
410
+ </div>
411
+
412
+ {hwrHit ? (
413
+ <Tag type={resolvedDoctor ? 'green' : 'red'} size="sm">
414
+ {hwrHit.membership?.full_name || 'Match'}
415
+ {hwrHit.membership?.registration_id ? ` · ${hwrHit.membership.registration_id}` : ' · no registration number'}
416
+ </Tag>
417
+ ) : null}
418
+
419
+ {(hwrHit || selectedProvider) && (
420
+ <div className={styles.row}>
421
+ <Dropdown
422
+ id="emt-handover-reg-body"
423
+ titleText="Regulation body"
424
+ label="Select regulation body"
425
+ items={[...REGULATION_BODIES]}
426
+ selectedItem={regulationBody}
427
+ onChange={({ selectedItem }) => setRegulationBody(normalizeRegulationBody(selectedItem))}
428
+ />
429
+ <p className={styles.fieldHint}>COC = Clinical Officers Council · NCK = Nursing Council of Kenya</p>
430
+ </div>
431
+ )}
432
+
433
+ {doctorSearchError && <p className={styles.errorText}>{doctorSearchError}</p>}
434
+ </div>
435
+ )}
436
+
437
+ {step === 'confirm' && (
438
+ <div className={styles.confirmBody}>
439
+ <p className={styles.confirmText}>
440
+ You are about to accept the handover for the following referral. An OTP will be sent to
441
+ the receiving doctor to confirm.
442
+ </p>
443
+ <div className={styles.detailGrid}>
444
+ <span className={styles.detailLabel}>Patient</span>
445
+ <span className={styles.detailValue}>{patientLine}</span>
446
+ <span className={styles.detailLabel}>CR ID</span>
447
+ <span className={styles.detailValue}>{referral.cr_id}</span>
448
+ <span className={styles.detailLabel}>Case number</span>
449
+ <span className={styles.detailValue}>{referral.case_number}</span>
450
+ <span className={styles.detailLabel}>Receiving doctor</span>
451
+ <span className={styles.detailValue}>
452
+ {resolvedDoctor?.name}{' '}
453
+ <Tag size="sm" type="gray">
454
+ {resolvedDoctor?.regulator} {resolvedDoctor?.identifier}
455
+ </Tag>
456
+ </span>
457
+ </div>
458
+ {error && <p className={styles.errorText}>{error}</p>}
459
+ </div>
460
+ )}
461
+
462
+ {step === 'otp' && (
463
+ <div className={styles.otpBody}>
464
+ <p className={styles.confirmText}>
465
+ Enter the OTP sent to <strong>{resolvedDoctor?.name}</strong> to complete the handover for{' '}
466
+ {patientLine}.
467
+ </p>
468
+ <OTPInput otpLength={6} onChange={setOtp} />
469
+ {error && <p className={styles.errorText}>{error}</p>}
470
+ </div>
471
+ )}
472
+ </div>
473
+
474
+ <ButtonSet className={styles.buttonSet}>
475
+ {step === 'doctor' && (
476
+ <>
477
+ <Button kind="secondary" onClick={handleClose}>
478
+ Cancel
479
+ </Button>
480
+ <Button kind="primary" onClick={() => setStep('confirm')} disabled={!resolvedDoctor || searchingHwr}>
481
+ Continue
482
+ </Button>
483
+ </>
484
+ )}
485
+ {step === 'confirm' && (
486
+ <>
487
+ <Button kind="ghost" onClick={() => setStep('doctor')} disabled={loading}>
488
+ Change doctor
489
+ </Button>
490
+ <Button kind="secondary" onClick={handleClose} disabled={loading}>
491
+ Cancel
492
+ </Button>
493
+ {loading ? (
494
+ <InlineLoading description="Sending OTP to doctor…" />
495
+ ) : (
496
+ <Button kind="primary" onClick={handleInitiate}>
497
+ Send OTP
498
+ </Button>
499
+ )}
500
+ </>
501
+ )}
502
+ {step === 'otp' && (
503
+ <>
504
+ <Button kind="ghost" onClick={handleResend} disabled={loading}>
505
+ Resend OTP
506
+ </Button>
507
+ <Button kind="secondary" onClick={handleClose} disabled={loading}>
508
+ Cancel
509
+ </Button>
510
+ {loading ? (
511
+ <InlineLoading description="Verifying…" />
512
+ ) : (
513
+ <Button kind="primary" onClick={handleVerify} disabled={otp.length < 4}>
514
+ Verify
515
+ </Button>
516
+ )}
517
+ </>
518
+ )}
519
+ </ButtonSet>
520
+ </div>
521
+ );
522
+ };
523
+
524
+ export default HandoverModal;
@@ -0,0 +1,88 @@
1
+ .container {
2
+ display: flex;
3
+ flex-direction: column;
4
+ height: 100%;
5
+ }
6
+
7
+ .heading {
8
+ padding: 1rem 1rem 0;
9
+ margin: 0;
10
+ }
11
+
12
+ .body {
13
+ flex: 1;
14
+ overflow: auto;
15
+ padding: 1rem;
16
+ }
17
+
18
+ .confirmBody,
19
+ .otpBody,
20
+ .doctorBody {
21
+ display: flex;
22
+ flex-direction: column;
23
+ gap: 1rem;
24
+ padding-top: 0.5rem;
25
+ }
26
+
27
+ .searchBlock {
28
+ display: flex;
29
+ flex-direction: column;
30
+ gap: 0.25rem;
31
+ }
32
+
33
+ .row {
34
+ display: flex;
35
+ align-items: flex-end;
36
+ gap: 0.75rem;
37
+ flex-wrap: wrap;
38
+ }
39
+
40
+ .fieldHint {
41
+ margin: 0;
42
+ color: #6f6f6f;
43
+ font-size: 0.75rem;
44
+ line-height: 1.125rem;
45
+ }
46
+
47
+ .confirmText {
48
+ margin: 0;
49
+ color: #525252;
50
+ font-size: 0.875rem;
51
+ line-height: 1.4rem;
52
+ }
53
+
54
+ .detailGrid {
55
+ display: grid;
56
+ grid-template-columns: minmax(8rem, 35%) 1fr;
57
+ row-gap: 0.5rem;
58
+ column-gap: 1rem;
59
+ padding: 0.75rem 0;
60
+ }
61
+
62
+ .detailLabel {
63
+ color: #6f6f6f;
64
+ font-size: 0.75rem;
65
+ text-transform: uppercase;
66
+ letter-spacing: 0.02rem;
67
+ align-self: center;
68
+ }
69
+
70
+ .detailValue {
71
+ color: #161616;
72
+ font-size: 0.875rem;
73
+ display: flex;
74
+ align-items: center;
75
+ gap: 0.5rem;
76
+ flex-wrap: wrap;
77
+ }
78
+
79
+ .errorText {
80
+ margin: 0;
81
+ color: #da1e28;
82
+ font-size: 0.8125rem;
83
+ }
84
+
85
+ .buttonSet {
86
+ padding: 1rem;
87
+ border-top: 1px solid var(--cds-border-subtle, #e0e0e0);
88
+ }