@openmrs/esm-patient-task-list-app 12.1.1-pre.10907
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +7 -0
- package/README.md +28 -0
- package/dist/105.js +1 -0
- package/dist/105.js.map +1 -0
- package/dist/117.js +1 -0
- package/dist/117.js.map +1 -0
- package/dist/149.js +1 -0
- package/dist/149.js.map +1 -0
- package/dist/229.js +43 -0
- package/dist/229.js.map +1 -0
- package/dist/304.js +1 -0
- package/dist/304.js.map +1 -0
- package/dist/339.js +1 -0
- package/dist/339.js.map +1 -0
- package/dist/378.js +1 -0
- package/dist/378.js.map +1 -0
- package/dist/396.js +1 -0
- package/dist/396.js.map +1 -0
- package/dist/409.js +6 -0
- package/dist/409.js.map +1 -0
- package/dist/466.js +1 -0
- package/dist/466.js.map +1 -0
- package/dist/61.js +1 -0
- package/dist/61.js.map +1 -0
- package/dist/66.js +1 -0
- package/dist/66.js.map +1 -0
- package/dist/697.js +1 -0
- package/dist/697.js.map +1 -0
- package/dist/712.js +1 -0
- package/dist/712.js.map +1 -0
- package/dist/720.js +1 -0
- package/dist/720.js.map +1 -0
- package/dist/752.js +1 -0
- package/dist/752.js.map +1 -0
- package/dist/771.js +1 -0
- package/dist/771.js.map +1 -0
- package/dist/789.js +1 -0
- package/dist/789.js.map +1 -0
- package/dist/989.js +1 -0
- package/dist/989.js.map +1 -0
- package/dist/main.js +6 -0
- package/dist/main.js.map +1 -0
- package/dist/openmrs-esm-patient-task-list-app.js +6 -0
- package/dist/openmrs-esm-patient-task-list-app.js.buildmanifest.json +651 -0
- package/dist/openmrs-esm-patient-task-list-app.js.map +1 -0
- package/dist/routes.json +1 -0
- package/jest.config.js +3 -0
- package/package.json +61 -0
- package/rspack.config.js +1 -0
- package/src/config-schema.ts +13 -0
- package/src/declarations.d.ts +3 -0
- package/src/index.ts +25 -0
- package/src/launch-button/task-list-launch-button.extension.tsx +20 -0
- package/src/loader/loader.component.tsx +10 -0
- package/src/loader/loader.scss +9 -0
- package/src/routes.json +28 -0
- package/src/types.d.ts +9 -0
- package/src/workspace/add-task-form.component.tsx +609 -0
- package/src/workspace/add-task-form.scss +49 -0
- package/src/workspace/add-task-form.test.tsx +615 -0
- package/src/workspace/delete-task.modal.test.tsx +99 -0
- package/src/workspace/delete-task.modal.tsx +71 -0
- package/src/workspace/delete-task.scss +7 -0
- package/src/workspace/task-details-view.component.tsx +212 -0
- package/src/workspace/task-details-view.scss +61 -0
- package/src/workspace/task-details-view.test.tsx +408 -0
- package/src/workspace/task-list-view.component.tsx +154 -0
- package/src/workspace/task-list-view.scss +111 -0
- package/src/workspace/task-list-view.test.tsx +246 -0
- package/src/workspace/task-list.resource.ts +543 -0
- package/src/workspace/task-list.scss +37 -0
- package/src/workspace/task-list.workspace.test.tsx +135 -0
- package/src/workspace/task-list.workspace.tsx +99 -0
- package/translations/en.json +66 -0
- package/tsconfig.json +4 -0
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
import { useMemo, useState } from 'react';
|
|
2
|
+
import useSWR from 'swr';
|
|
3
|
+
import useSWRImmutable from 'swr/immutable';
|
|
4
|
+
import { type TFunction } from 'i18next';
|
|
5
|
+
import {
|
|
6
|
+
type FetchResponse,
|
|
7
|
+
fhirBaseUrl,
|
|
8
|
+
openmrsFetch,
|
|
9
|
+
restBaseUrl,
|
|
10
|
+
parseDate,
|
|
11
|
+
useDebounce,
|
|
12
|
+
useConfig,
|
|
13
|
+
} from '@openmrs/esm-framework';
|
|
14
|
+
import { type Config } from '../config-schema';
|
|
15
|
+
import { type CarePlan } from '../types';
|
|
16
|
+
|
|
17
|
+
export interface Assignee {
|
|
18
|
+
uuid: string;
|
|
19
|
+
display?: string;
|
|
20
|
+
type: 'person' | 'role';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type DueDateType = 'THIS_VISIT' | 'NEXT_VISIT' | 'DATE';
|
|
24
|
+
|
|
25
|
+
export type Priority = 'high' | 'medium' | 'low';
|
|
26
|
+
|
|
27
|
+
export function getPriorityLabel(priority: Priority, t: TFunction): string {
|
|
28
|
+
const labels: Record<Priority, string> = {
|
|
29
|
+
high: t('priorityHigh', 'High'),
|
|
30
|
+
medium: t('priorityMedium', 'Medium'),
|
|
31
|
+
low: t('priorityLow', 'Low'),
|
|
32
|
+
};
|
|
33
|
+
return labels[priority];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface Task {
|
|
37
|
+
uuid: string;
|
|
38
|
+
name: string;
|
|
39
|
+
status?: string;
|
|
40
|
+
dueDate?: TaskDueDate;
|
|
41
|
+
createdDate: Date;
|
|
42
|
+
rationale?: string;
|
|
43
|
+
assignee?: Assignee;
|
|
44
|
+
createdBy?: string;
|
|
45
|
+
completed: boolean;
|
|
46
|
+
priority?: Priority;
|
|
47
|
+
systemTaskUuid?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type TaskDueDate = TaskDueDateDate | TaskDueDateVisit;
|
|
51
|
+
|
|
52
|
+
export type TaskDueDateDate = {
|
|
53
|
+
type: Extract<DueDateType, 'DATE'>;
|
|
54
|
+
date: Date;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export type TaskDueDateVisit = {
|
|
58
|
+
type: Extract<DueDateType, 'THIS_VISIT' | 'NEXT_VISIT'>;
|
|
59
|
+
referenceVisitUuid?: string;
|
|
60
|
+
date?: Date;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export interface TaskInput {
|
|
64
|
+
name: string;
|
|
65
|
+
dueDate?: TaskDueDate;
|
|
66
|
+
rationale?: string;
|
|
67
|
+
assignee?: Assignee;
|
|
68
|
+
priority?: Priority;
|
|
69
|
+
systemTaskUuid?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface FHIRCarePlanResponse {
|
|
73
|
+
entry: Array<{
|
|
74
|
+
resource: CarePlan;
|
|
75
|
+
}>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface ProviderSearchResponse {
|
|
79
|
+
results: Array<{
|
|
80
|
+
uuid: string;
|
|
81
|
+
display: string;
|
|
82
|
+
}>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface ProviderRoleSearchResponse {
|
|
86
|
+
results: Array<{
|
|
87
|
+
uuid: string;
|
|
88
|
+
name: string;
|
|
89
|
+
}>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const carePlanEndpoint = `${restBaseUrl}/tasks/careplan`;
|
|
93
|
+
|
|
94
|
+
export function taskListSWRKey(patientUuid: string) {
|
|
95
|
+
return `${carePlanEndpoint}?subject=Patient/${patientUuid}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function useTaskList(patientUuid: string) {
|
|
99
|
+
const swrKey = taskListSWRKey(patientUuid);
|
|
100
|
+
const { data, isLoading, error, mutate } = useSWR<{ data: FHIRCarePlanResponse }>(swrKey, openmrsFetch);
|
|
101
|
+
|
|
102
|
+
const tasks = useMemo(() => {
|
|
103
|
+
const parsedTasks = data?.data?.entry?.map((entry) => createTaskFromCarePlan(entry.resource)) ?? [];
|
|
104
|
+
const validTasks = parsedTasks.filter((task) => Boolean(task.uuid));
|
|
105
|
+
const activeTasks = validTasks.filter((task) => task.status !== 'cancelled');
|
|
106
|
+
|
|
107
|
+
return activeTasks.sort((a, b) => {
|
|
108
|
+
if (a.completed !== b.completed) {
|
|
109
|
+
return a.completed ? 1 : -1;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const aDue = a.dueDate?.date?.getTime() ?? Infinity;
|
|
113
|
+
const bDue = b.dueDate?.date?.getTime() ?? Infinity;
|
|
114
|
+
|
|
115
|
+
return aDue - bDue;
|
|
116
|
+
});
|
|
117
|
+
}, [data]);
|
|
118
|
+
|
|
119
|
+
return { tasks, isLoading, error, mutate };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function saveTask(patientUuid: string, task: TaskInput) {
|
|
123
|
+
const carePlan = buildCarePlan(patientUuid, task);
|
|
124
|
+
|
|
125
|
+
return openmrsFetch(carePlanEndpoint, {
|
|
126
|
+
headers: {
|
|
127
|
+
'Content-Type': 'application/json',
|
|
128
|
+
},
|
|
129
|
+
method: 'POST',
|
|
130
|
+
body: JSON.stringify(carePlan),
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function updateTask(patientUuid: string, task: Task) {
|
|
135
|
+
const carePlan = buildCarePlan(patientUuid, task);
|
|
136
|
+
|
|
137
|
+
return openmrsFetch(`${carePlanEndpoint}/${task.uuid}`, {
|
|
138
|
+
headers: {
|
|
139
|
+
'Content-Type': 'application/json',
|
|
140
|
+
},
|
|
141
|
+
method: 'PUT',
|
|
142
|
+
body: JSON.stringify(carePlan),
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function setTaskStatusCompleted(patientUuid: string, task: Task, completed: boolean) {
|
|
147
|
+
const status = completed ? 'completed' : task.status && task.status !== 'completed' ? task.status : 'in-progress';
|
|
148
|
+
|
|
149
|
+
return updateTask(patientUuid, {
|
|
150
|
+
...task,
|
|
151
|
+
completed,
|
|
152
|
+
status,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function useTask(taskUuid: string) {
|
|
157
|
+
const swrKey = taskUuid ? `${carePlanEndpoint}/${taskUuid}` : null;
|
|
158
|
+
const { data, isLoading, error, mutate } = useSWR<{ data: CarePlan }>(swrKey, openmrsFetch);
|
|
159
|
+
|
|
160
|
+
const task = useMemo(() => {
|
|
161
|
+
if (!data?.data) {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
return createTaskFromCarePlan(data.data);
|
|
165
|
+
}, [data]);
|
|
166
|
+
|
|
167
|
+
return { task, isLoading, error, mutate };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function deleteTask(patientUuid: string, task: Task) {
|
|
171
|
+
return updateTask(patientUuid, {
|
|
172
|
+
...task,
|
|
173
|
+
status: 'cancelled',
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function createTaskFromCarePlan(carePlan: CarePlan): Task {
|
|
178
|
+
const activity = carePlan?.activity?.[0];
|
|
179
|
+
const detail = activity?.detail;
|
|
180
|
+
|
|
181
|
+
const status = detail?.status;
|
|
182
|
+
|
|
183
|
+
const performers = detail?.performer ?? [];
|
|
184
|
+
const { dueDate, dueDateType, referenceVisitUuid } = extractDueDate(detail);
|
|
185
|
+
const priority = extractPriority(detail);
|
|
186
|
+
const createdBy = carePlan?.author?.display;
|
|
187
|
+
const systemTaskUuid = extractSystemTaskUuid(carePlan.instantiatesCanonical);
|
|
188
|
+
|
|
189
|
+
const taskDueDate: Task['dueDate'] = dueDateType
|
|
190
|
+
? dueDateType === 'DATE'
|
|
191
|
+
? { type: 'DATE', date: dueDate! }
|
|
192
|
+
: { type: dueDateType, date: dueDate, referenceVisitUuid }
|
|
193
|
+
: undefined;
|
|
194
|
+
|
|
195
|
+
const task: Task = {
|
|
196
|
+
uuid: carePlan.id ?? '',
|
|
197
|
+
name: detail?.description ?? '',
|
|
198
|
+
status,
|
|
199
|
+
createdDate: parseDate(carePlan.created),
|
|
200
|
+
dueDate: taskDueDate,
|
|
201
|
+
rationale: carePlan.description ?? undefined,
|
|
202
|
+
createdBy,
|
|
203
|
+
completed: status === 'completed',
|
|
204
|
+
priority,
|
|
205
|
+
systemTaskUuid,
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
performers.forEach((performer) => {
|
|
209
|
+
const assignment = parseAssignment(performer);
|
|
210
|
+
if (!assignment) {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (assignment.type === 'provider') {
|
|
214
|
+
task.assignee = assignment.value;
|
|
215
|
+
}
|
|
216
|
+
if (assignment.type === 'providerRole') {
|
|
217
|
+
task.assignee = assignment.value;
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
return task;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function extractSystemTaskUuid(instantiatesCanonical?: string[]): string | undefined {
|
|
225
|
+
if (!instantiatesCanonical || instantiatesCanonical.length === 0) {
|
|
226
|
+
return undefined;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
for (const reference of instantiatesCanonical) {
|
|
230
|
+
if (reference.startsWith('PlanDefinition/')) {
|
|
231
|
+
return reference.substring('PlanDefinition/'.length);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return undefined;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function buildCarePlan(patientUuid: string, task: Partial<Task>) {
|
|
239
|
+
const performer: Array<fhir.Reference> = [];
|
|
240
|
+
|
|
241
|
+
if (task.assignee?.type === 'role' && task.assignee?.uuid) {
|
|
242
|
+
performer.push({
|
|
243
|
+
reference: `PractitionerRole/${task.assignee.uuid}`,
|
|
244
|
+
display: task.assignee.display,
|
|
245
|
+
});
|
|
246
|
+
} else if (task.assignee?.uuid) {
|
|
247
|
+
performer.push({
|
|
248
|
+
reference: `Practitioner/${task.assignee.uuid}`,
|
|
249
|
+
display: task.assignee.display,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const detail: fhir.CarePlanActivityDetail = {
|
|
254
|
+
status: task.status ?? 'not-started',
|
|
255
|
+
description: task.name,
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
if (performer.length > 0) {
|
|
259
|
+
detail.performer = performer;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Handle due date. Due date is stored as scheduledPeriod. The type of visit is stored
|
|
263
|
+
// using an activity-dueKind extension. For visit-based due dates, the visit UUID is stored in an
|
|
264
|
+
// encounter-associatedEncounter extension.
|
|
265
|
+
detail.extension = detail.extension || [];
|
|
266
|
+
|
|
267
|
+
if (task.dueDate?.type === 'THIS_VISIT' || task.dueDate?.type === 'NEXT_VISIT') {
|
|
268
|
+
// Visit-based types: use scheduledPeriod (end date set server-side if visit ended)
|
|
269
|
+
detail.scheduledPeriod = {};
|
|
270
|
+
|
|
271
|
+
detail.extension.push({
|
|
272
|
+
url: 'http://openmrs.org/fhir/StructureDefinition/activity-dueKind',
|
|
273
|
+
valueCode: task.dueDate?.type === 'THIS_VISIT' ? 'this-visit' : 'next-visit',
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
if (task.dueDate?.referenceVisitUuid) {
|
|
277
|
+
detail.extension.push({
|
|
278
|
+
url: 'http://hl7.org/fhir/StructureDefinition/encounter-associatedEncounter',
|
|
279
|
+
valueReference: {
|
|
280
|
+
reference: `Encounter/${task.dueDate.referenceVisitUuid}`,
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
} else if (task.dueDate?.type === 'DATE' && task.dueDate?.date) {
|
|
285
|
+
detail.scheduledPeriod = {
|
|
286
|
+
end: task.dueDate.date.toISOString(),
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
detail.extension.push({
|
|
290
|
+
url: 'http://openmrs.org/fhir/StructureDefinition/activity-dueKind',
|
|
291
|
+
valueCode: 'date',
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Add priority extension if set
|
|
296
|
+
if (task.priority) {
|
|
297
|
+
detail.extension.push({
|
|
298
|
+
url: 'http://openmrs.org/fhir/StructureDefinition/activity-priority',
|
|
299
|
+
valueCode: task.priority,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const carePlan: CarePlan = {
|
|
304
|
+
resourceType: 'CarePlan',
|
|
305
|
+
status: task.completed ? 'completed' : 'active',
|
|
306
|
+
intent: 'plan',
|
|
307
|
+
description: task.rationale,
|
|
308
|
+
subject: {
|
|
309
|
+
reference: `Patient/${patientUuid}`,
|
|
310
|
+
},
|
|
311
|
+
activity: [
|
|
312
|
+
{
|
|
313
|
+
detail,
|
|
314
|
+
},
|
|
315
|
+
],
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
if (task.uuid) {
|
|
319
|
+
carePlan.id = task.uuid;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (task.systemTaskUuid) {
|
|
323
|
+
carePlan.instantiatesCanonical = [`PlanDefinition/${task.systemTaskUuid}`];
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return carePlan;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function parseAssignment(
|
|
330
|
+
performer: fhir.Reference,
|
|
331
|
+
): { type: 'provider'; value: Assignee } | { type: 'providerRole'; value: Assignee } | undefined {
|
|
332
|
+
if (!performer) {
|
|
333
|
+
return undefined;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const reference = performer.reference ?? '';
|
|
337
|
+
const [resourceType, identifier] = reference.split('/');
|
|
338
|
+
|
|
339
|
+
if (resourceType === 'Practitioner' && identifier) {
|
|
340
|
+
return {
|
|
341
|
+
type: 'provider',
|
|
342
|
+
value: {
|
|
343
|
+
uuid: identifier,
|
|
344
|
+
display: performer.display ?? undefined,
|
|
345
|
+
type: 'person',
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (resourceType === 'PractitionerRole' && identifier) {
|
|
351
|
+
return {
|
|
352
|
+
type: 'providerRole',
|
|
353
|
+
value: {
|
|
354
|
+
uuid: identifier,
|
|
355
|
+
display: performer.display ?? undefined,
|
|
356
|
+
type: 'role',
|
|
357
|
+
},
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
console.warn('Unknown performer type', performer);
|
|
362
|
+
return undefined;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function extractDueDate(detail?: fhir.CarePlanActivityDetail): {
|
|
366
|
+
dueDate?: Date;
|
|
367
|
+
dueDateType?: DueDateType;
|
|
368
|
+
referenceVisitUuid?: string;
|
|
369
|
+
} {
|
|
370
|
+
if (!detail) {
|
|
371
|
+
return {};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Read due date type and reference visit from extensions
|
|
375
|
+
let dueDateType: DueDateType | undefined;
|
|
376
|
+
let referenceVisitUuid: string | undefined;
|
|
377
|
+
|
|
378
|
+
if (detail.extension) {
|
|
379
|
+
for (const ext of detail.extension) {
|
|
380
|
+
if (ext.url === 'http://openmrs.org/fhir/StructureDefinition/activity-dueKind') {
|
|
381
|
+
const value = (ext as any).valueCode || (ext as any).valueString;
|
|
382
|
+
if (value === 'this-visit') {
|
|
383
|
+
dueDateType = 'THIS_VISIT';
|
|
384
|
+
} else if (value === 'next-visit') {
|
|
385
|
+
dueDateType = 'NEXT_VISIT';
|
|
386
|
+
} else if (value === 'date') {
|
|
387
|
+
dueDateType = 'DATE';
|
|
388
|
+
}
|
|
389
|
+
} else if (ext.url === 'http://hl7.org/fhir/StructureDefinition/encounter-associatedEncounter') {
|
|
390
|
+
const ref = (ext as any).valueReference?.reference;
|
|
391
|
+
if (ref && ref.startsWith('Encounter/')) {
|
|
392
|
+
referenceVisitUuid = ref.substring('Encounter/'.length);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Read due date from scheduledPeriod.end (always present if date is known)
|
|
399
|
+
let dueDate: string | undefined;
|
|
400
|
+
if (detail.scheduledPeriod?.end) {
|
|
401
|
+
dueDate = detail.scheduledPeriod.end;
|
|
402
|
+
// If no dueKind extension was found but we have an end date, assume DATE type
|
|
403
|
+
if (!dueDateType) {
|
|
404
|
+
dueDateType = 'DATE';
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// For visit-based types, dueDate will be null/undefined if visit hasn't ended
|
|
409
|
+
// For DATE type, dueDate will contain the actual date
|
|
410
|
+
|
|
411
|
+
return {
|
|
412
|
+
dueDate: dueDate ? parseDate(dueDate) : undefined,
|
|
413
|
+
dueDateType,
|
|
414
|
+
referenceVisitUuid,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function extractPriority(detail?: fhir.CarePlanActivityDetail): Priority | undefined {
|
|
419
|
+
if (!detail?.extension) {
|
|
420
|
+
return undefined;
|
|
421
|
+
}
|
|
422
|
+
return extractExtensionPriority(detail.extension);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function extractExtensionPriority(extensions: Array<{ url: string; [key: string]: any }>): Priority | undefined {
|
|
426
|
+
for (const ext of extensions) {
|
|
427
|
+
if (ext.url === 'http://openmrs.org/fhir/StructureDefinition/activity-priority') {
|
|
428
|
+
const value = ext.valueCode || ext.valueString;
|
|
429
|
+
if (value === 'high' || value === 'medium' || value === 'low') {
|
|
430
|
+
return value;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return undefined;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export function useFetchProviders() {
|
|
438
|
+
const [query, setQuery] = useState<string>('');
|
|
439
|
+
const debouncedQuery = useDebounce(query, 300);
|
|
440
|
+
const url =
|
|
441
|
+
debouncedQuery.length < 2
|
|
442
|
+
? null
|
|
443
|
+
: `${restBaseUrl}/provider?q=${encodeURIComponent(debouncedQuery)}&v=custom:(uuid,display)`;
|
|
444
|
+
const { data, isLoading, error } = useSWR<FetchResponse<ProviderSearchResponse>>(url, openmrsFetch);
|
|
445
|
+
|
|
446
|
+
return {
|
|
447
|
+
providers: data?.data?.results ?? [],
|
|
448
|
+
setProviderQuery: setQuery,
|
|
449
|
+
isLoading,
|
|
450
|
+
error,
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
export function useProviderRoles() {
|
|
455
|
+
const { allowAssigningProviderRole } = useConfig<Config>();
|
|
456
|
+
const url = allowAssigningProviderRole ? `${restBaseUrl}/providerrole?v=custom:(uuid,name)` : null;
|
|
457
|
+
const response = useSWRImmutable<FetchResponse<ProviderRoleSearchResponse>>(url, openmrsFetch);
|
|
458
|
+
const results = response?.data?.data?.results ?? [];
|
|
459
|
+
return results.map((result) => ({
|
|
460
|
+
id: result.uuid,
|
|
461
|
+
label: result.name,
|
|
462
|
+
}));
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// PlanDefinition types for system tasks
|
|
466
|
+
export interface PlanDefinition {
|
|
467
|
+
resourceType: 'PlanDefinition';
|
|
468
|
+
id: string;
|
|
469
|
+
name?: string;
|
|
470
|
+
title?: string;
|
|
471
|
+
status?: string;
|
|
472
|
+
description?: string;
|
|
473
|
+
action?: Array<{
|
|
474
|
+
title?: string;
|
|
475
|
+
reason?: Array<{ text?: string }>;
|
|
476
|
+
participant?: Array<{
|
|
477
|
+
role?: { coding?: Array<{ code?: string; display?: string }> };
|
|
478
|
+
}>;
|
|
479
|
+
extension?: Array<{
|
|
480
|
+
url: string;
|
|
481
|
+
valueCode?: string;
|
|
482
|
+
}>;
|
|
483
|
+
}>;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
export interface FHIRPlanDefinitionBundle {
|
|
487
|
+
resourceType: 'Bundle';
|
|
488
|
+
entry?: Array<{
|
|
489
|
+
resource: PlanDefinition;
|
|
490
|
+
}>;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
export interface SystemTask {
|
|
494
|
+
uuid: string;
|
|
495
|
+
name: string;
|
|
496
|
+
title: string;
|
|
497
|
+
description?: string;
|
|
498
|
+
priority?: Priority;
|
|
499
|
+
defaultAssigneeRoleUuid?: string;
|
|
500
|
+
defaultAssigneeRoleDisplay?: string;
|
|
501
|
+
rationale?: string;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function planDefinitionToSystemTask(pd: PlanDefinition): SystemTask {
|
|
505
|
+
const action = pd.action?.[0];
|
|
506
|
+
|
|
507
|
+
const priority = action?.extension ? extractExtensionPriority(action.extension) : undefined;
|
|
508
|
+
|
|
509
|
+
// Extract default assignee role from action participant
|
|
510
|
+
let defaultAssigneeRoleUuid: string | undefined;
|
|
511
|
+
let defaultAssigneeRoleDisplay: string | undefined;
|
|
512
|
+
if (action?.participant?.[0]?.role?.coding?.[0]) {
|
|
513
|
+
const coding = action.participant[0].role.coding[0];
|
|
514
|
+
defaultAssigneeRoleUuid = coding.code;
|
|
515
|
+
defaultAssigneeRoleDisplay = coding.display;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Extract rationale from action reason
|
|
519
|
+
const rationale = action?.reason?.[0]?.text;
|
|
520
|
+
|
|
521
|
+
return {
|
|
522
|
+
uuid: pd.id,
|
|
523
|
+
name: pd.name ?? '',
|
|
524
|
+
title: pd.title ?? pd.name ?? '',
|
|
525
|
+
description: pd.description,
|
|
526
|
+
priority,
|
|
527
|
+
defaultAssigneeRoleUuid,
|
|
528
|
+
defaultAssigneeRoleDisplay,
|
|
529
|
+
rationale,
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
export function useSystemTasks() {
|
|
534
|
+
const url = `${fhirBaseUrl}/PlanDefinition?status=active`;
|
|
535
|
+
const { data, isLoading, error } = useSWRImmutable<FetchResponse<FHIRPlanDefinitionBundle>>(url, openmrsFetch);
|
|
536
|
+
|
|
537
|
+
const systemTasks = useMemo(() => {
|
|
538
|
+
const entries = data?.data?.entry ?? [];
|
|
539
|
+
return entries.map((entry) => planDefinitionToSystemTask(entry.resource));
|
|
540
|
+
}, [data]);
|
|
541
|
+
|
|
542
|
+
return { systemTasks, isLoading, error };
|
|
543
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
@use '@carbon/layout';
|
|
2
|
+
@use '@openmrs/esm-styleguide/src/vars' as *;
|
|
3
|
+
|
|
4
|
+
.workspaceContainer {
|
|
5
|
+
display: flex;
|
|
6
|
+
flex-direction: column;
|
|
7
|
+
height: 100%;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.addTaskButtonContainer {
|
|
11
|
+
display: flex;
|
|
12
|
+
align-items: center;
|
|
13
|
+
justify-content: center;
|
|
14
|
+
color: $ui-02;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.backButton {
|
|
18
|
+
padding: 0 layout.$spacing-05;
|
|
19
|
+
|
|
20
|
+
button {
|
|
21
|
+
display: flex;
|
|
22
|
+
|
|
23
|
+
svg {
|
|
24
|
+
order: 1;
|
|
25
|
+
margin-right: layout.$spacing-05;
|
|
26
|
+
margin-left: 0 !important;
|
|
27
|
+
fill: currentColor !important;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
span {
|
|
31
|
+
order: 2;
|
|
32
|
+
|
|
33
|
+
// Center text vertically within span
|
|
34
|
+
line-height: 2em;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import userEvent from '@testing-library/user-event';
|
|
4
|
+
import TaskListWorkspace from './task-list.workspace';
|
|
5
|
+
|
|
6
|
+
jest.mock('./task-list.resource', () => ({
|
|
7
|
+
useTaskList: jest.fn(),
|
|
8
|
+
useTask: jest.fn(() => ({ task: null, isLoading: false, error: null, mutate: jest.fn() })),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
// Mock child components to avoid heavy dependency chains
|
|
12
|
+
jest.mock('./task-list-view.component', () => {
|
|
13
|
+
return function MockTaskListView({ onTaskClick }: { onTaskClick?: (task: any) => void }) {
|
|
14
|
+
return (
|
|
15
|
+
<div data-testid="task-list-view">
|
|
16
|
+
<button onClick={() => onTaskClick?.({ uuid: 'task-1', name: 'Mock Task' })}>Mock Task</button>
|
|
17
|
+
</div>
|
|
18
|
+
);
|
|
19
|
+
};
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
jest.mock('./add-task-form.component', () => {
|
|
23
|
+
return function MockAddTaskForm({ onClose, editTaskUuid }: { onClose: () => void; editTaskUuid?: string }) {
|
|
24
|
+
return (
|
|
25
|
+
<div data-testid={editTaskUuid ? 'edit-task-form' : 'add-task-form'}>
|
|
26
|
+
<span>{editTaskUuid ? 'Editing task' : 'Adding task'}</span>
|
|
27
|
+
<button onClick={onClose}>Form back</button>
|
|
28
|
+
</div>
|
|
29
|
+
);
|
|
30
|
+
};
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
jest.mock('./task-details-view.component', () => {
|
|
34
|
+
return function MockTaskDetailsView({ onBack, onEdit }: { onBack: () => void; onEdit?: (task: any) => void }) {
|
|
35
|
+
return (
|
|
36
|
+
<div data-testid="task-details-view">
|
|
37
|
+
<span>Task details</span>
|
|
38
|
+
<button onClick={onBack}>Details back</button>
|
|
39
|
+
{onEdit && <button onClick={() => onEdit({ uuid: 'task-1', name: 'Mock Task' })}>Edit</button>}
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
};
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const defaultProps = {
|
|
46
|
+
groupProps: { patientUuid: 'patient-uuid-123', visitContext: { uuid: 'visit-uuid' } },
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
describe('TaskListWorkspace', () => {
|
|
50
|
+
it('renders the task list view by default', () => {
|
|
51
|
+
render(<TaskListWorkspace {...(defaultProps as any)} />);
|
|
52
|
+
|
|
53
|
+
expect(screen.getByTestId('task-list-view')).toBeInTheDocument();
|
|
54
|
+
expect(screen.getByRole('button', { name: /add task/i })).toBeInTheDocument();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('does not show back button in list view', () => {
|
|
58
|
+
render(<TaskListWorkspace {...(defaultProps as any)} />);
|
|
59
|
+
|
|
60
|
+
expect(screen.queryByText(/back to task list/i)).not.toBeInTheDocument();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('navigates to form view when Add Task button is clicked', async () => {
|
|
64
|
+
const user = userEvent.setup();
|
|
65
|
+
|
|
66
|
+
render(<TaskListWorkspace {...(defaultProps as any)} />);
|
|
67
|
+
|
|
68
|
+
await user.click(screen.getByRole('button', { name: /add task/i }));
|
|
69
|
+
|
|
70
|
+
expect(screen.getByTestId('add-task-form')).toBeInTheDocument();
|
|
71
|
+
expect(screen.getByText(/back to task list/i)).toBeInTheDocument();
|
|
72
|
+
expect(screen.queryByTestId('task-list-view')).not.toBeInTheDocument();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('navigates to details view when a task is clicked', async () => {
|
|
76
|
+
const user = userEvent.setup();
|
|
77
|
+
|
|
78
|
+
render(<TaskListWorkspace {...(defaultProps as any)} />);
|
|
79
|
+
|
|
80
|
+
await user.click(screen.getByText('Mock Task'));
|
|
81
|
+
|
|
82
|
+
expect(screen.getByTestId('task-details-view')).toBeInTheDocument();
|
|
83
|
+
expect(screen.getByText(/back to task list/i)).toBeInTheDocument();
|
|
84
|
+
expect(screen.queryByTestId('task-list-view')).not.toBeInTheDocument();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('navigates back to list view from details', async () => {
|
|
88
|
+
const user = userEvent.setup();
|
|
89
|
+
|
|
90
|
+
render(<TaskListWorkspace {...(defaultProps as any)} />);
|
|
91
|
+
|
|
92
|
+
// Go to details
|
|
93
|
+
await user.click(screen.getByText('Mock Task'));
|
|
94
|
+
expect(screen.getByTestId('task-details-view')).toBeInTheDocument();
|
|
95
|
+
|
|
96
|
+
// Go back via back button in the workspace header
|
|
97
|
+
await user.click(screen.getByRole('button', { name: /back to task list/i }));
|
|
98
|
+
|
|
99
|
+
expect(screen.getByTestId('task-list-view')).toBeInTheDocument();
|
|
100
|
+
expect(screen.getByRole('button', { name: /add task/i })).toBeInTheDocument();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('navigates to edit view from details', async () => {
|
|
104
|
+
const user = userEvent.setup();
|
|
105
|
+
|
|
106
|
+
render(<TaskListWorkspace {...(defaultProps as any)} />);
|
|
107
|
+
|
|
108
|
+
// Go to details
|
|
109
|
+
await user.click(screen.getByText('Mock Task'));
|
|
110
|
+
|
|
111
|
+
// Click edit
|
|
112
|
+
await user.click(screen.getByRole('button', { name: /edit/i }));
|
|
113
|
+
|
|
114
|
+
expect(screen.getByTestId('edit-task-form')).toBeInTheDocument();
|
|
115
|
+
expect(screen.getByText(/back to task details/i)).toBeInTheDocument();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('navigates from edit back to details', async () => {
|
|
119
|
+
const user = userEvent.setup();
|
|
120
|
+
|
|
121
|
+
render(<TaskListWorkspace {...(defaultProps as any)} />);
|
|
122
|
+
|
|
123
|
+
// Go to details
|
|
124
|
+
await user.click(screen.getByText('Mock Task'));
|
|
125
|
+
|
|
126
|
+
// Go to edit
|
|
127
|
+
await user.click(screen.getByRole('button', { name: /edit/i }));
|
|
128
|
+
expect(screen.getByTestId('edit-task-form')).toBeInTheDocument();
|
|
129
|
+
|
|
130
|
+
// Go back to details via workspace header back button
|
|
131
|
+
await user.click(screen.getByRole('button', { name: /back to task details/i }));
|
|
132
|
+
|
|
133
|
+
expect(screen.getByTestId('task-details-view')).toBeInTheDocument();
|
|
134
|
+
});
|
|
135
|
+
});
|