VIrtual PR 4 Changes for move patients
This commit is contained in:
parent
2241886ca7
commit
a0b2020286
|
|
@ -0,0 +1,28 @@
|
||||||
|
import cron from 'node-cron';
|
||||||
|
import { env } from '../config/env.js';
|
||||||
|
import { appendTodaySessions } from '../services/programSessions.js';
|
||||||
|
import { todayYmd } from '../services/schedule.js';
|
||||||
|
|
||||||
|
// Adds today's session to open cohorts whose planned sessions are used up.
|
||||||
|
export async function runCohortExtension(dateYmd) {
|
||||||
|
const date = dateYmd || todayYmd(env.APP_TIMEZONE);
|
||||||
|
const added = await appendTodaySessions(date);
|
||||||
|
console.log(`[cohort-extension] ${date}: added=${added}`);
|
||||||
|
return added;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startCohortExtensionJobs() {
|
||||||
|
cron.schedule('30 5 * * *', async () => {
|
||||||
|
try {
|
||||||
|
await runCohortExtension();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[cohort-extension] cron failed', err);
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
timezone: env.APP_TIMEZONE,
|
||||||
|
name: 'cohort-extension',
|
||||||
|
noOverlap: true,
|
||||||
|
});
|
||||||
|
console.log(`[cohort-extension] scheduled daily 05:30 in ${env.APP_TIMEZONE}`);
|
||||||
|
runCohortExtension().catch((err) => console.error('[cohort-extension] startup run failed', err));
|
||||||
|
}
|
||||||
|
|
@ -34,6 +34,7 @@ const onboardedPatientSchema = new mongoose.Schema(
|
||||||
onboardedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
|
onboardedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
|
||||||
assignedEducator: { type: mongoose.Schema.Types.ObjectId, ref: 'User', default: null },
|
assignedEducator: { type: mongoose.Schema.Types.ObjectId, ref: 'User', default: null },
|
||||||
cohort: { type: mongoose.Schema.Types.ObjectId, ref: 'Cohort', default: null },
|
cohort: { type: mongoose.Schema.Types.ObjectId, ref: 'Cohort', default: null },
|
||||||
|
cohortJoinedOn: { type: String, default: '' }, // date the patient was added to the current cohort
|
||||||
reviewStatus: {
|
reviewStatus: {
|
||||||
type: String,
|
type: String,
|
||||||
enum: ['pending', 'confirmed', 'rejected'],
|
enum: ['pending', 'confirmed', 'rejected'],
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ import { migratePatient } from '../services/matching.js';
|
||||||
import { notifyPaymentReview } from '../services/notify.js';
|
import { notifyPaymentReview } from '../services/notify.js';
|
||||||
import { attendanceProgress, ensureSessionRecords, patientProgramStats } from '../services/programSessions.js';
|
import { attendanceProgress, ensureSessionRecords, patientProgramStats } from '../services/programSessions.js';
|
||||||
import { assertConfiguredSlot, getAppSettings, slotLabel } from '../services/settings.js';
|
import { assertConfiguredSlot, getAppSettings, slotLabel } from '../services/settings.js';
|
||||||
import { toYmd, weekdayOf, ymdToDate } from '../services/schedule.js';
|
import { toYmd, weekdayOf } from '../services/schedule.js';
|
||||||
|
|
||||||
const objectId = Joi.string().hex().length(24);
|
const objectId = Joi.string().hex().length(24);
|
||||||
function textField(payload, key) {
|
function textField(payload, key) {
|
||||||
|
|
@ -208,11 +208,10 @@ function canReview(request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findAssignableCohort(cohortId, currentCohortId) {
|
async function findAssignableCohort(cohortId, currentCohortId) {
|
||||||
const today = ymdToDate(toYmd(new Date()));
|
|
||||||
const cohort = await Cohort.findById(cohortId);
|
const cohort = await Cohort.findById(cohortId);
|
||||||
if (!cohort) throw Boom.badRequest('Select a future cohort.');
|
if (!cohort) throw Boom.badRequest('Select an ongoing or upcoming cohort.');
|
||||||
const isCurrent = currentCohortId && String(cohort._id) === String(currentCohortId);
|
const isCurrent = currentCohortId && String(cohort._id) === String(currentCohortId);
|
||||||
if (!isCurrent && cohort.startDate < today) throw Boom.badRequest('Select a future cohort.');
|
if (!isCurrent && cohort.status === 'completed') throw Boom.badRequest('Select an ongoing or upcoming cohort.');
|
||||||
return cohort;
|
return cohort;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -287,7 +286,7 @@ export function onboardingRoutes(server) {
|
||||||
return {
|
return {
|
||||||
items: items.map((item) => {
|
items: items.map((item) => {
|
||||||
const progress = stats.get(String(item._id)) || { attended: 0, total: 0 };
|
const progress = stats.get(String(item._id)) || { attended: 0, total: 0 };
|
||||||
return { ...toOnboardedPatientDto(item), progress: { attended: progress.attended, total: progress.total } };
|
return { ...toOnboardedPatientDto(item), progress: { attended: progress.attended, total: progress.total, firstDate: progress.firstDate || '' } };
|
||||||
}),
|
}),
|
||||||
total,
|
total,
|
||||||
page: Number(page),
|
page: Number(page),
|
||||||
|
|
@ -307,10 +306,9 @@ export function onboardingRoutes(server) {
|
||||||
path: '/api/onboarding/available-cohorts',
|
path: '/api/onboarding/available-cohorts',
|
||||||
options: { pre: [{ method: requirePermission('onboarding', 'view') }] },
|
options: { pre: [{ method: requirePermission('onboarding', 'view') }] },
|
||||||
handler: async (request) => {
|
handler: async (request) => {
|
||||||
const today = ymdToDate(toYmd(new Date()));
|
|
||||||
const language = String(request.query.language || '').trim();
|
const language = String(request.query.language || '').trim();
|
||||||
const include = String(request.query.include || '').trim();
|
const include = String(request.query.include || '').trim();
|
||||||
const filter = { startDate: { $gte: today }, status: { $nin: ['proposed', 'completed'] } };
|
const filter = { status: { $nin: ['proposed', 'completed'] } };
|
||||||
if (language) filter.language = language;
|
if (language) filter.language = language;
|
||||||
const items = await Cohort.find(filter)
|
const items = await Cohort.find(filter)
|
||||||
.populate('virtualPrt', 'firstName lastName email')
|
.populate('virtualPrt', 'firstName lastName email')
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { toCohortSessionDto } from '../utils/serialize.js';
|
||||||
import {
|
import {
|
||||||
generateMeetsForDate,
|
generateMeetsForDate,
|
||||||
listSessions,
|
listSessions,
|
||||||
|
patientSessionNumbers,
|
||||||
pushSession,
|
pushSession,
|
||||||
saveSessionRecords,
|
saveSessionRecords,
|
||||||
} from '../services/programSessions.js';
|
} from '../services/programSessions.js';
|
||||||
|
|
@ -104,7 +105,10 @@ export function programSessionRoutes(server) {
|
||||||
pre: [{ method: requirePermission('program_sessions', 'view') }],
|
pre: [{ method: requirePermission('program_sessions', 'view') }],
|
||||||
validate: { params: Joi.object({ id: objectId.required() }) },
|
validate: { params: Joi.object({ id: objectId.required() }) },
|
||||||
},
|
},
|
||||||
handler: async (request) => toCohortSessionDto(await findScopedSession(request)),
|
handler: async (request) => {
|
||||||
|
const session = await findScopedSession(request);
|
||||||
|
return toCohortSessionDto(session, await patientSessionNumbers([session]));
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.route({
|
server.route({
|
||||||
|
|
@ -194,7 +198,7 @@ export function programSessionRoutes(server) {
|
||||||
const saved = await saveSessionRecords(session, request.payload.records, { completed: request.payload.completed });
|
const saved = await saveSessionRecords(session, request.payload.records, { completed: request.payload.completed });
|
||||||
await saved.populate('records.patient', 'name mobile');
|
await saved.populate('records.patient', 'name mobile');
|
||||||
await saved.populate({ path: 'cohort', populate: { path: 'virtualPrt', select: 'firstName lastName email' } });
|
await saved.populate({ path: 'cohort', populate: { path: 'virtualPrt', select: 'firstName lastName email' } });
|
||||||
return toCohortSessionDto(saved);
|
return toCohortSessionDto(saved, await patientSessionNumbers([saved]));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,14 @@ function sessionMark(session, patientId) {
|
||||||
return record?.attended ? 'P' : 'A';
|
return record?.attended ? 'P' : 'A';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sessionRecord(session, patientId) {
|
||||||
|
return (session.records || []).find((item) => String(item.patient?._id || item.patient) === patientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function vitalValue(value) {
|
||||||
|
return value == null || value === '' ? '' : value;
|
||||||
|
}
|
||||||
|
|
||||||
export function reportRoutes(server) {
|
export function reportRoutes(server) {
|
||||||
server.route({
|
server.route({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
|
@ -60,7 +68,7 @@ export function reportRoutes(server) {
|
||||||
const stats = await patientProgramStats(patients.map((item) => item._id));
|
const stats = await patientProgramStats(patients.map((item) => item._id));
|
||||||
return {
|
return {
|
||||||
items: patients.map((patient) => {
|
items: patients.map((patient) => {
|
||||||
const progress = stats.get(String(patient._id)) || { attended: 0, total: 0, vitals: [] };
|
const progress = stats.get(String(patient._id)) || { attended: 0, total: 0, completed: 0, assigned: 0, firstDate: '', vitals: [] };
|
||||||
const pii = readOnboardedPii(patient);
|
const pii = readOnboardedPii(patient);
|
||||||
return {
|
return {
|
||||||
id: String(patient._id),
|
id: String(patient._id),
|
||||||
|
|
@ -73,6 +81,9 @@ export function reportRoutes(server) {
|
||||||
prt: patient.assignedEducator
|
prt: patient.assignedEducator
|
||||||
? { id: String(patient.assignedEducator._id), firstName: patient.assignedEducator.firstName, lastName: patient.assignedEducator.lastName }
|
? { id: String(patient.assignedEducator._id), firstName: patient.assignedEducator.firstName, lastName: patient.assignedEducator.lastName }
|
||||||
: null,
|
: null,
|
||||||
|
firstSession: progress.firstDate || '',
|
||||||
|
completed: progress.completed || 0,
|
||||||
|
assigned: progress.assigned || 0,
|
||||||
attended: progress.attended,
|
attended: progress.attended,
|
||||||
total: progress.total,
|
total: progress.total,
|
||||||
completion: progress.total ? `${progress.attended}/${progress.total}` : '0/0',
|
completion: progress.total ? `${progress.attended}/${progress.total}` : '0/0',
|
||||||
|
|
@ -199,6 +210,69 @@ export function reportRoutes(server) {
|
||||||
items.push([`${prt.firstName} ${prt.lastName}`, used, max, Math.max(0, max - used), run, cancelledOwn]);
|
items.push([`${prt.firstName} ${prt.lastName}`, used, max, Math.max(0, max - used), run, cancelledOwn]);
|
||||||
}
|
}
|
||||||
rows = [['PRT', 'Used', 'Max', 'Idle', 'Sessions', 'Cancelled'], ...items];
|
rows = [['PRT', 'Used', 'Max', 'Idle', 'Sessions', 'Cancelled'], ...items];
|
||||||
|
} else if (type === 'vitals') {
|
||||||
|
const patients = await listOnboardedForReports({ virtualPrtId: scopedPrt(request), diseaseType: request.query.diseaseType || undefined });
|
||||||
|
const cohortIds = [...new Set(patients.map((patient) => patient.cohort?._id).filter(Boolean))];
|
||||||
|
const sessions = cohortIds.length
|
||||||
|
? await CohortSession.find({ cohort: { $in: cohortIds }, status: 'completed' })
|
||||||
|
.select('cohort index date records.patient records.attended records.pre records.post')
|
||||||
|
.sort({ date: 1, index: 1 })
|
||||||
|
: [];
|
||||||
|
const byCohort = new Map();
|
||||||
|
for (const session of sessions) {
|
||||||
|
const id = String(session.cohort);
|
||||||
|
const list = byCohort.get(id) || [];
|
||||||
|
list.push(session);
|
||||||
|
byCohort.set(id, list);
|
||||||
|
}
|
||||||
|
const included = patients.filter((patient) => {
|
||||||
|
const cohortId = patient.cohort?._id ? String(patient.cohort._id) : '';
|
||||||
|
const meetings = cohortId ? (byCohort.get(cohortId) || []) : [];
|
||||||
|
return meetings.some((session) => sessionRecord(session, String(patient._id)));
|
||||||
|
});
|
||||||
|
const groups = included.map((patient) => {
|
||||||
|
const cohortId = patient.cohort?._id ? String(patient.cohort._id) : '';
|
||||||
|
const meetings = (cohortId ? (byCohort.get(cohortId) || []) : [])
|
||||||
|
.filter((session) => sessionRecord(session, String(patient._id)));
|
||||||
|
return { patient, meetings };
|
||||||
|
});
|
||||||
|
const blank = ['', '', '', '', '', ''];
|
||||||
|
const lines = [];
|
||||||
|
for (const { patient, meetings } of groups) {
|
||||||
|
const pii = readOnboardedPii(patient);
|
||||||
|
const identity = [
|
||||||
|
pii.name,
|
||||||
|
pii.mobile,
|
||||||
|
patient.diseaseType || '',
|
||||||
|
patient.cohort?.name || '',
|
||||||
|
patient.cohort?.startDate ? toYmd(patient.cohort.startDate) : '',
|
||||||
|
];
|
||||||
|
for (const session of meetings) {
|
||||||
|
const record = sessionRecord(session, String(patient._id));
|
||||||
|
const pre = record?.pre || {};
|
||||||
|
const post = record?.post || {};
|
||||||
|
lines.push({
|
||||||
|
date: session.date,
|
||||||
|
name: pii.name || '',
|
||||||
|
row: [
|
||||||
|
...identity,
|
||||||
|
`${session.date} (${sessionMark(session, String(patient._id))})`,
|
||||||
|
vitalValue(pre.bp),
|
||||||
|
vitalValue(pre.spo2),
|
||||||
|
vitalValue(pre.pulse),
|
||||||
|
vitalValue(post.bp),
|
||||||
|
vitalValue(post.spo2),
|
||||||
|
vitalValue(post.pulse),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.sort((a, b) => a.date.localeCompare(b.date) || a.name.localeCompare(b.name));
|
||||||
|
rows = [
|
||||||
|
['Name', 'Mobile', 'Disease', 'Cohort', 'Start date', 'Session date', 'Pre', '', '', 'Post', '', ''],
|
||||||
|
[...blank, 'BP', 'SpO2', 'Pulse', 'BP', 'SpO2', 'Pulse'],
|
||||||
|
...lines.map((line) => line.row),
|
||||||
|
];
|
||||||
} else {
|
} else {
|
||||||
const patients = await listOnboardedForReports({ virtualPrtId: scopedPrt(request), diseaseType: request.query.diseaseType || undefined });
|
const patients = await listOnboardedForReports({ virtualPrtId: scopedPrt(request), diseaseType: request.query.diseaseType || undefined });
|
||||||
const cohortIds = [...new Set(patients.map((patient) => patient.cohort?._id).filter(Boolean))];
|
const cohortIds = [...new Set(patients.map((patient) => patient.cohort?._id).filter(Boolean))];
|
||||||
|
|
@ -214,12 +288,15 @@ export function reportRoutes(server) {
|
||||||
list.push(session);
|
list.push(session);
|
||||||
byCohort.set(id, list);
|
byCohort.set(id, list);
|
||||||
}
|
}
|
||||||
|
const stats = await patientProgramStats(patients.map((item) => item._id));
|
||||||
const widest = [...byCohort.values()].reduce((max, list) => Math.max(max, list.length), 0);
|
const widest = [...byCohort.values()].reduce((max, list) => Math.max(max, list.length), 0);
|
||||||
const headers = ['Name', 'Mobile', 'Disease', 'Cohort', 'PRT', 'Start date', ...Array.from({ length: widest }, (_, index) => `Session ${index + 1}`)];
|
const headers = ['Name', 'Mobile', 'Disease', 'Cohort', 'PRT', 'Start date', 'First session', 'Completed', 'Assigned', ...Array.from({ length: widest }, (_, index) => `Session ${index + 1}`)];
|
||||||
rows = [headers, ...patients.map((patient) => {
|
rows = [headers, ...patients.map((patient) => {
|
||||||
const pii = readOnboardedPii(patient);
|
const pii = readOnboardedPii(patient);
|
||||||
|
const progress = stats.get(String(patient._id)) || {};
|
||||||
const cohortId = patient.cohort?._id ? String(patient.cohort._id) : '';
|
const cohortId = patient.cohort?._id ? String(patient.cohort._id) : '';
|
||||||
const meetings = cohortId ? (byCohort.get(cohortId) || []) : [];
|
const meetings = (cohortId ? (byCohort.get(cohortId) || []) : [])
|
||||||
|
.filter((session) => sessionRecord(session, String(patient._id)));
|
||||||
const marks = meetings.map((session) => sessionMark(session, String(patient._id)));
|
const marks = meetings.map((session) => sessionMark(session, String(patient._id)));
|
||||||
while (marks.length < widest) marks.push(''); // shorter cohorts leave the extra session cells blank
|
while (marks.length < widest) marks.push(''); // shorter cohorts leave the extra session cells blank
|
||||||
return [
|
return [
|
||||||
|
|
@ -229,6 +306,9 @@ export function reportRoutes(server) {
|
||||||
patient.cohort?.name || '',
|
patient.cohort?.name || '',
|
||||||
patient.assignedEducator ? `${patient.assignedEducator.firstName} ${patient.assignedEducator.lastName}` : '',
|
patient.assignedEducator ? `${patient.assignedEducator.firstName} ${patient.assignedEducator.lastName}` : '',
|
||||||
patient.cohort?.startDate ? toYmd(patient.cohort.startDate) : '',
|
patient.cohort?.startDate ? toYmd(patient.cohort.startDate) : '',
|
||||||
|
progress.firstDate || '',
|
||||||
|
progress.completed || 0,
|
||||||
|
progress.assigned || 0,
|
||||||
...marks,
|
...marks,
|
||||||
];
|
];
|
||||||
})];
|
})];
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import { matchingRoutes } from './routes/matching.js';
|
||||||
import { programSessionRoutes } from './routes/programSessions.js';
|
import { programSessionRoutes } from './routes/programSessions.js';
|
||||||
import { reportRoutes } from './routes/reports.js';
|
import { reportRoutes } from './routes/reports.js';
|
||||||
import { startMeetingJobs } from './jobs/meetings.js';
|
import { startMeetingJobs } from './jobs/meetings.js';
|
||||||
|
import { startCohortExtensionJobs } from './jobs/cohortExtension.js';
|
||||||
import { startWhatsappWorker } from './services/whatsappQueue.js';
|
import { startWhatsappWorker } from './services/whatsappQueue.js';
|
||||||
import { startMatchingJobs } from './jobs/matching.js';
|
import { startMatchingJobs } from './jobs/matching.js';
|
||||||
import { startRetentionJobs } from './jobs/retention.js';
|
import { startRetentionJobs } from './jobs/retention.js';
|
||||||
|
|
@ -107,6 +108,7 @@ export async function createServer() {
|
||||||
auditRoutes(server);
|
auditRoutes(server);
|
||||||
whatsappRoutes(server);
|
whatsappRoutes(server);
|
||||||
startMeetingJobs();
|
startMeetingJobs();
|
||||||
|
startCohortExtensionJobs();
|
||||||
startWhatsappWorker();
|
startWhatsappWorker();
|
||||||
startMatchingJobs().catch((err) => console.error('[matching] schedule failed', err));
|
startMatchingJobs().catch((err) => console.error('[matching] schedule failed', err));
|
||||||
startRetentionJobs().catch((err) => console.error('[retention] schedule failed', err));
|
startRetentionJobs().catch((err) => console.error('[retention] schedule failed', err));
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import { Cohort } from '../models/Cohort.js';
|
import { Cohort } from '../models/Cohort.js';
|
||||||
|
import { CohortSession } from '../models/CohortSession.js';
|
||||||
import { OnboardedPatient } from '../models/OnboardedPatient.js';
|
import { OnboardedPatient } from '../models/OnboardedPatient.js';
|
||||||
import { addPatientToWhatsapp, removePatientFromWhatsapp, syncWhatsappMembership } from './whatsapp.js';
|
import { addPatientToWhatsapp, removePatientFromWhatsapp, syncWhatsappMembership } from './whatsapp.js';
|
||||||
import { applyOnboardedPii, hashMobile, isMobileNumber } from './cryptoPii.js';
|
import { applyOnboardedPii, hashMobile, isMobileNumber } from './cryptoPii.js';
|
||||||
import { toYmd, weekdayOf } from './schedule.js';
|
import { env } from '../config/env.js';
|
||||||
|
import { toYmd, todayYmd, weekdayOf } from './schedule.js';
|
||||||
import Boom from '@hapi/boom';
|
import Boom from '@hapi/boom';
|
||||||
|
|
||||||
async function safeWhatsapp(label, fn) {
|
async function safeWhatsapp(label, fn) {
|
||||||
|
|
@ -23,9 +25,17 @@ export async function syncCohortPatients(cohort, previousPatientIds = []) {
|
||||||
{ $set: { assignedEducator: cohort.virtualPrt, cohort: cohort._id, matchingStatus: 'assigned' } },
|
{ $set: { assignedEducator: cohort.virtualPrt, cohort: cohort._id, matchingStatus: 'assigned' } },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const added = nextIds.filter((id) => !prevIds.includes(id));
|
||||||
|
if (added.length) {
|
||||||
|
await OnboardedPatient.updateMany({ _id: { $in: added } }, { $set: { cohortJoinedOn: todayYmd(env.APP_TIMEZONE) } });
|
||||||
|
}
|
||||||
|
|
||||||
const removed = prevIds.filter((id) => !nextIds.includes(id));
|
const removed = prevIds.filter((id) => !nextIds.includes(id));
|
||||||
for (const id of removed) {
|
for (const id of removed) {
|
||||||
|
await CohortSession.updateMany(
|
||||||
|
{ cohort: cohort._id, status: 'scheduled', date: { $gte: todayYmd(env.APP_TIMEZONE) } },
|
||||||
|
{ $pull: { records: { patient: id } } },
|
||||||
|
);
|
||||||
const other = await Cohort.findOne({ _id: { $ne: cohort._id }, patients: id });
|
const other = await Cohort.findOne({ _id: { $ne: cohort._id }, patients: id });
|
||||||
if (other) {
|
if (other) {
|
||||||
await OnboardedPatient.updateOne(
|
await OnboardedPatient.updateOne(
|
||||||
|
|
@ -52,13 +62,19 @@ export async function syncCohortPatients(cohort, previousPatientIds = []) {
|
||||||
export async function addPatientToCohort(cohort, patientId) {
|
export async function addPatientToCohort(cohort, patientId) {
|
||||||
const id = String(patientId);
|
const id = String(patientId);
|
||||||
const current = (cohort.patients || []).map((item) => String(item._id || item));
|
const current = (cohort.patients || []).map((item) => String(item._id || item));
|
||||||
if (!current.includes(id)) {
|
const joined = !current.includes(id);
|
||||||
|
if (joined) {
|
||||||
cohort.patients.push(patientId);
|
cohort.patients.push(patientId);
|
||||||
await cohort.save();
|
await cohort.save();
|
||||||
}
|
}
|
||||||
await OnboardedPatient.updateOne(
|
await OnboardedPatient.updateOne(
|
||||||
{ _id: patientId },
|
{ _id: patientId },
|
||||||
{ $set: { assignedEducator: cohort.virtualPrt, cohort: cohort._id, matchingStatus: 'assigned' } },
|
{ $set: {
|
||||||
|
assignedEducator: cohort.virtualPrt,
|
||||||
|
cohort: cohort._id,
|
||||||
|
matchingStatus: 'assigned',
|
||||||
|
...(joined ? { cohortJoinedOn: todayYmd(env.APP_TIMEZONE) } : {}),
|
||||||
|
} },
|
||||||
);
|
);
|
||||||
await safeWhatsapp('add patient', () => addPatientToWhatsapp(cohort, patientId));
|
await safeWhatsapp('add patient', () => addPatientToWhatsapp(cohort, patientId));
|
||||||
}
|
}
|
||||||
|
|
@ -66,6 +82,10 @@ export async function addPatientToCohort(cohort, patientId) {
|
||||||
export async function removePatientFromCohort(cohortId, patientId) {
|
export async function removePatientFromCohort(cohortId, patientId) {
|
||||||
if (!cohortId || !patientId) return;
|
if (!cohortId || !patientId) return;
|
||||||
await Cohort.updateOne({ _id: cohortId }, { $pull: { patients: patientId } });
|
await Cohort.updateOne({ _id: cohortId }, { $pull: { patients: patientId } });
|
||||||
|
await CohortSession.updateMany(
|
||||||
|
{ cohort: cohortId, status: 'scheduled', date: { $gte: todayYmd(env.APP_TIMEZONE) } },
|
||||||
|
{ $pull: { records: { patient: patientId } } },
|
||||||
|
);
|
||||||
await safeWhatsapp('remove patient', () => removePatientFromWhatsapp(cohortId, patientId));
|
await safeWhatsapp('remove patient', () => removePatientFromWhatsapp(cohortId, patientId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,27 @@ function manualSessionCount(cohort) {
|
||||||
return cohort.sixDayWeek ? 60 : 30;
|
return cohort.sixDayWeek ? 60 : 30;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Program length for a cohort: its schedule count, otherwise the settings count, or 60 for a six-day manual cohort.
|
||||||
|
export async function cohortProgramLength(cohort, settings) {
|
||||||
|
const ownCount = cohort ? await scheduleSessionCount(cohort.scheduleId) : null;
|
||||||
|
return Math.min(Math.max(Number(manualSessionCount(cohort) || ownCount || settings?.upcomingSessionCount) || 30, 1), 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Completed sessions this patient was on, across every cohort.
|
||||||
|
async function finishedSessionCounts(patientIds) {
|
||||||
|
const ids = patientIds.map(String);
|
||||||
|
const counts = new Map(ids.map((id) => [id, 0]));
|
||||||
|
if (!ids.length) return counts;
|
||||||
|
const sessions = await CohortSession.find({ 'records.patient': { $in: ids }, status: 'completed' }).select('records.patient');
|
||||||
|
for (const session of sessions) {
|
||||||
|
for (const record of session.records || []) {
|
||||||
|
const id = String(record.patient);
|
||||||
|
if (counts.has(id)) counts.set(id, counts.get(id) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
// Creates the session list once. If rows exist, only adds dates when the list is short.
|
// Creates the session list once. If rows exist, only adds dates when the list is short.
|
||||||
export async function persistCohortSessions(cohort) {
|
export async function persistCohortSessions(cohort) {
|
||||||
const settings = await getAppSettings();
|
const settings = await getAppSettings();
|
||||||
|
|
@ -223,21 +244,32 @@ export async function rescheduleOpenSessions(cohort) {
|
||||||
return sessions;
|
return sessions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Books each patient onto the upcoming sessions they still have left. Finished sessions are not changed.
|
||||||
export async function ensureSessionRecords(cohort) {
|
export async function ensureSessionRecords(cohort) {
|
||||||
const patients = patientIds(cohort).map(String);
|
const patients = patientIds(cohort).map(String);
|
||||||
if (!patients.length) return;
|
if (!patients.length) return;
|
||||||
const sessions = await CohortSession.find({ cohort: cohort._id, status: { $in: ['scheduled', 'completed'] } });
|
const settings = await getAppSettings();
|
||||||
for (const session of sessions) {
|
const length = await cohortProgramLength(cohort, settings);
|
||||||
const have = new Set((session.records || []).map((record) => String(record.patient)));
|
const finished = await finishedSessionCounts(patients);
|
||||||
let dirty = false;
|
const today = todayYmd(env.APP_TIMEZONE);
|
||||||
|
const sessions = await CohortSession.find({ cohort: cohort._id, status: 'scheduled', date: { $gte: today } }).sort({ date: 1, index: 1 });
|
||||||
|
const dirty = new Set();
|
||||||
for (const id of patients) {
|
for (const id of patients) {
|
||||||
if (!have.has(id)) {
|
const remaining = Math.max(0, length - (finished.get(id) || 0));
|
||||||
|
const keep = new Set(sessions.slice(0, remaining).map((session) => String(session._id)));
|
||||||
|
for (const session of sessions) {
|
||||||
|
const index = (session.records || []).findIndex((record) => String(record.patient) === id);
|
||||||
|
const should = keep.has(String(session._id));
|
||||||
|
if (should && index < 0) {
|
||||||
session.records.push({ patient: id, attended: false, pre: {}, post: {} });
|
session.records.push({ patient: id, attended: false, pre: {}, post: {} });
|
||||||
dirty = true;
|
dirty.add(session);
|
||||||
|
} else if (!should && index >= 0) {
|
||||||
|
session.records.splice(index, 1);
|
||||||
|
dirty.add(session);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (dirty) await session.save();
|
|
||||||
}
|
}
|
||||||
|
for (const session of dirty) await session.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Next date for an extra schedule, skipping unticked days, Sunday, holiday, and leave.
|
// Next date for an extra schedule, skipping unticked days, Sunday, holiday, and leave.
|
||||||
|
|
@ -285,6 +317,37 @@ export async function nextAppendDate(cohort) {
|
||||||
return cursor;
|
return cursor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Adds today's session to open cohorts with no future session left, when today is their next meeting day.
|
||||||
|
export async function appendTodaySessions(date) {
|
||||||
|
const cohorts = await Cohort.find(liveCohortFilter());
|
||||||
|
let added = 0;
|
||||||
|
for (const cohort of cohorts) {
|
||||||
|
if (await CohortSession.exists({ cohort: cohort._id, date })) continue;
|
||||||
|
if (await CohortSession.exists({ cohort: cohort._id, status: 'scheduled', date: { $gt: date } })) continue;
|
||||||
|
if (await nextAppendDate(cohort) !== date) continue;
|
||||||
|
const ids = patientIds(cohort).map(String);
|
||||||
|
const length = await cohortProgramLength(cohort, await getAppSettings());
|
||||||
|
const finished = await finishedSessionCounts(ids);
|
||||||
|
if (!ids.some((id) => (finished.get(id) || 0) < length)) continue; // everyone already finished their sessions
|
||||||
|
const last = await CohortSession.findOne({ cohort: cohort._id }).sort({ index: -1 });
|
||||||
|
const link = await CohortSession.findOne({ cohort: cohort._id, meetUrl: { $ne: '' } }) || {};
|
||||||
|
await CohortSession.create({
|
||||||
|
cohort: cohort._id,
|
||||||
|
index: (last?.index || 0) + 1,
|
||||||
|
date,
|
||||||
|
slot: cohort.slot,
|
||||||
|
status: 'scheduled',
|
||||||
|
googleEventId: link.googleEventId || '',
|
||||||
|
meetUrl: link.meetUrl || '',
|
||||||
|
htmlLink: link.htmlLink || '',
|
||||||
|
records: [],
|
||||||
|
});
|
||||||
|
await ensureSessionRecords(cohort);
|
||||||
|
added += 1;
|
||||||
|
}
|
||||||
|
return added;
|
||||||
|
}
|
||||||
|
|
||||||
async function sessionHost(session, cohort) {
|
async function sessionHost(session, cohort) {
|
||||||
const coverId = session.coverPrt?._id || session.coverPrt;
|
const coverId = session.coverPrt?._id || session.coverPrt;
|
||||||
if (coverId) {
|
if (coverId) {
|
||||||
|
|
@ -322,6 +385,7 @@ export async function pushSession(session, reason, { notifyAdmins = false } = {}
|
||||||
htmlLink: session.htmlLink || '',
|
htmlLink: session.htmlLink || '',
|
||||||
records: recordsFor(patientIds(cohort)),
|
records: recordsFor(patientIds(cohort)),
|
||||||
});
|
});
|
||||||
|
await ensureSessionRecords(cohort);
|
||||||
|
|
||||||
const admins = notifyAdmins ? await adminUsers() : [];
|
const admins = notifyAdmins ? await adminUsers() : [];
|
||||||
await notifySessionPushed({
|
await notifySessionPushed({
|
||||||
|
|
@ -621,10 +685,35 @@ export async function attendanceProgress(patientId) {
|
||||||
attended += 1;
|
attended += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { attended, total: sessions.length };
|
const patient = await OnboardedPatient.findById(patientId).populate('cohort', 'createdAs sixDayWeek scheduleId');
|
||||||
|
const settings = await getAppSettings();
|
||||||
|
const total = await cohortProgramLength(patient?.cohort, settings);
|
||||||
|
return { attended, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each patient's own session number on each session, counted by date across all their cohorts.
|
||||||
|
export async function patientSessionNumbers(sessions) {
|
||||||
|
const ids = [...new Set(sessions.flatMap((session) => (session.records || []).map((record) => String(record.patient?._id || record.patient))))];
|
||||||
|
const numbers = new Map();
|
||||||
|
if (!ids.length) return numbers;
|
||||||
|
const all = await CohortSession.find({ 'records.patient': { $in: ids }, status: { $ne: 'cancelled' } })
|
||||||
|
.select('_id date index records.patient')
|
||||||
|
.sort({ date: 1, index: 1 });
|
||||||
|
const seen = new Map();
|
||||||
|
for (const session of all) {
|
||||||
|
for (const record of session.records || []) {
|
||||||
|
const id = String(record.patient);
|
||||||
|
if (!ids.includes(id)) continue;
|
||||||
|
const next = (seen.get(id) || 0) + 1;
|
||||||
|
seen.set(id, next);
|
||||||
|
numbers.set(`${session._id}:${id}`, next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return numbers;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveSessionRecords(session, records = [], { completed } = {}) {
|
export async function saveSessionRecords(session, records = [], { completed } = {}) {
|
||||||
|
const numbers = await patientSessionNumbers([session]);
|
||||||
const byPatient = new Map((session.records || []).map((record) => [String(record.patient._id || record.patient), record]));
|
const byPatient = new Map((session.records || []).map((record) => [String(record.patient._id || record.patient), record]));
|
||||||
for (const incoming of records) {
|
for (const incoming of records) {
|
||||||
const id = String(incoming.patientId || incoming.patient);
|
const id = String(incoming.patientId || incoming.patient);
|
||||||
|
|
@ -644,7 +733,7 @@ export async function saveSessionRecords(session, records = [], { completed } =
|
||||||
pulse: incoming.post.pulse ?? current.post?.pulse ?? null,
|
pulse: incoming.post.pulse ?? current.post?.pulse ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (incoming.assessment && (session.index === 15 || session.index === 30)) {
|
if (incoming.assessment && [15, 30].includes(numbers.get(`${session._id}:${id}`))) {
|
||||||
const previous = current.assessment || {};
|
const previous = current.assessment || {};
|
||||||
current.assessment = {
|
current.assessment = {
|
||||||
fev1: incoming.assessment.fev1 ?? previous.fev1 ?? '',
|
fev1: incoming.assessment.fev1 ?? previous.fev1 ?? '',
|
||||||
|
|
@ -726,13 +815,15 @@ export async function moveSessionEvent(session, nextDate) {
|
||||||
export async function patientProgramStats(patientIds) {
|
export async function patientProgramStats(patientIds) {
|
||||||
const ids = patientIds.map((id) => String(id));
|
const ids = patientIds.map((id) => String(id));
|
||||||
const sessions = await CohortSession.find({ 'records.patient': { $in: ids }, status: { $in: ['scheduled', 'completed'] } });
|
const sessions = await CohortSession.find({ 'records.patient': { $in: ids }, status: { $in: ['scheduled', 'completed'] } });
|
||||||
const byPatient = new Map(ids.map((id) => [id, { attended: 0, total: 0, vitals: [] }]));
|
const byPatient = new Map(ids.map((id) => [id, { attended: 0, total: 0, completed: 0, assigned: 0, firstDate: '', vitals: [] }]));
|
||||||
for (const session of sessions) {
|
for (const session of sessions) {
|
||||||
for (const record of session.records || []) {
|
for (const record of session.records || []) {
|
||||||
const id = String(record.patient);
|
const id = String(record.patient);
|
||||||
const row = byPatient.get(id);
|
const row = byPatient.get(id);
|
||||||
if (!row) continue;
|
if (!row) continue;
|
||||||
row.total += 1;
|
if (session.status === 'completed') row.completed += 1;
|
||||||
|
else row.assigned += 1;
|
||||||
|
if (!row.firstDate || session.date < row.firstDate) row.firstDate = session.date;
|
||||||
if (record.attended) row.attended += 1;
|
if (record.attended) row.attended += 1;
|
||||||
if (record.pre?.spo2 != null || record.post?.spo2 != null || record.pre?.pulse != null) {
|
if (record.pre?.spo2 != null || record.post?.spo2 != null || record.pre?.pulse != null) {
|
||||||
row.vitals.push({
|
row.vitals.push({
|
||||||
|
|
@ -743,6 +834,19 @@ export async function patientProgramStats(patientIds) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const settings = await getAppSettings();
|
||||||
|
const people = await OnboardedPatient.find({ _id: { $in: ids } }).populate('cohort', 'createdAs sixDayWeek scheduleId');
|
||||||
|
const lengths = new Map();
|
||||||
|
for (const person of people) {
|
||||||
|
const id = String(person._id);
|
||||||
|
const row = byPatient.get(id);
|
||||||
|
if (!row) continue;
|
||||||
|
const key = person.cohort ? String(person.cohort._id) : '';
|
||||||
|
if (!lengths.has(key)) lengths.set(key, await cohortProgramLength(person.cohort, settings));
|
||||||
|
const total = lengths.get(key);
|
||||||
|
row.total = total;
|
||||||
|
row.assigned = Math.max(0, total - row.completed);
|
||||||
|
}
|
||||||
return byPatient;
|
return byPatient;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -184,6 +184,7 @@ export function toOnboardedPatientDto(item) {
|
||||||
diseaseType: item.diseaseType || '',
|
diseaseType: item.diseaseType || '',
|
||||||
preferredWeekday: item.preferredWeekday ?? null,
|
preferredWeekday: item.preferredWeekday ?? null,
|
||||||
sessionDate: item.sessionDate || null,
|
sessionDate: item.sessionDate || null,
|
||||||
|
cohortJoinedOn: item.cohortJoinedOn || '',
|
||||||
slot: item.slot || '',
|
slot: item.slot || '',
|
||||||
matchingStatus: item.matchingStatus || 'unassigned',
|
matchingStatus: item.matchingStatus || 'unassigned',
|
||||||
rejectReason: item.rejectReason || '',
|
rejectReason: item.rejectReason || '',
|
||||||
|
|
@ -310,7 +311,7 @@ export function toLeaveDto(leave) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toCohortSessionDto(session) {
|
export function toCohortSessionDto(session, numbers = null) {
|
||||||
if (!session) return null;
|
if (!session) return null;
|
||||||
const cohort = session.cohort && typeof session.cohort === 'object' ? session.cohort : null;
|
const cohort = session.cohort && typeof session.cohort === 'object' ? session.cohort : null;
|
||||||
return {
|
return {
|
||||||
|
|
@ -343,6 +344,7 @@ export function toCohortSessionDto(session) {
|
||||||
? { id: String(record.patient._id), name: readOnboardedPii(record.patient).name, mobile: readOnboardedPii(record.patient).mobile }
|
? { id: String(record.patient._id), name: readOnboardedPii(record.patient).name, mobile: readOnboardedPii(record.patient).mobile }
|
||||||
: { id: String(record.patient) },
|
: { id: String(record.patient) },
|
||||||
attended: Boolean(record.attended),
|
attended: Boolean(record.attended),
|
||||||
|
patientSessionNo: numbers instanceof Map ? (numbers.get(`${session._id}:${String(record.patient?._id || record.patient)}`) ?? null) : null, // .map() passes an index here
|
||||||
pre: { bp: record.pre?.bp || '', spo2: record.pre?.spo2 ?? null, pulse: record.pre?.pulse ?? null },
|
pre: { bp: record.pre?.bp || '', spo2: record.pre?.spo2 ?? null, pulse: record.pre?.pulse ?? null },
|
||||||
post: { bp: record.post?.bp || '', spo2: record.post?.spo2 ?? null, pulse: record.post?.pulse ?? null },
|
post: { bp: record.post?.bp || '', spo2: record.post?.spo2 ?? null, pulse: record.post?.pulse ?? null },
|
||||||
assessment: {
|
assessment: {
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ export function rosterFromSession(session) {
|
||||||
patientId: record.patient?.id,
|
patientId: record.patient?.id,
|
||||||
name: record.patient?.name || 'Patient',
|
name: record.patient?.name || 'Patient',
|
||||||
attended: Boolean(record.attended),
|
attended: Boolean(record.attended),
|
||||||
|
sessionNo: record.patientSessionNo ?? null,
|
||||||
pre: { ...emptyVitals(), ...record.pre, spo2: record.pre?.spo2 ?? '', pulse: record.pre?.pulse ?? '' },
|
pre: { ...emptyVitals(), ...record.pre, spo2: record.pre?.spo2 ?? '', pulse: record.pre?.pulse ?? '' },
|
||||||
post: { ...emptyVitals(), ...record.post, spo2: record.post?.spo2 ?? '', pulse: record.post?.pulse ?? '' },
|
post: { ...emptyVitals(), ...record.post, spo2: record.post?.spo2 ?? '', pulse: record.post?.pulse ?? '' },
|
||||||
assessment: { ...emptyAssessment(), ...record.assessment },
|
assessment: { ...emptyAssessment(), ...record.assessment },
|
||||||
|
|
@ -101,7 +102,7 @@ export default function SessionVitalsModal({ roster, onChange, onClose, onSaved
|
||||||
<Modal show={Boolean(roster)} onHide={onClose} size="lg">
|
<Modal show={Boolean(roster)} onHide={onClose} size="lg">
|
||||||
<ModalHeader closeButton>
|
<ModalHeader closeButton>
|
||||||
<h5 className="modal-title">
|
<h5 className="modal-title">
|
||||||
Attendance and vitals{isMilestone(roster?.index) ? ` · session ${roster.index}` : ''}
|
Attendance and vitals{roster?.index ? ` · cohort session ${roster.index}` : ''}
|
||||||
</h5>
|
</h5>
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
|
|
@ -109,7 +110,7 @@ export default function SessionVitalsModal({ roster, onChange, onClose, onSaved
|
||||||
{(roster?.records || []).length ? (roster.records || []).map((record, index) => (
|
{(roster?.records || []).length ? (roster.records || []).map((record, index) => (
|
||||||
<div key={record.patientId || index} className="border rounded p-3 mb-3">
|
<div key={record.patientId || index} className="border rounded p-3 mb-3">
|
||||||
<div className="d-flex justify-content-between align-items-center mb-2">
|
<div className="d-flex justify-content-between align-items-center mb-2">
|
||||||
<strong>{record.name}</strong>
|
<strong>{record.name}{record.sessionNo ? <span className="text-muted small ms-2">session {record.sessionNo}</span> : null}</strong>
|
||||||
<FormCheck
|
<FormCheck
|
||||||
type="switch"
|
type="switch"
|
||||||
label="Attended"
|
label="Attended"
|
||||||
|
|
@ -149,7 +150,7 @@ export default function SessionVitalsModal({ roster, onChange, onClose, onSaved
|
||||||
</Col>
|
</Col>
|
||||||
))}
|
))}
|
||||||
</Row>
|
</Row>
|
||||||
{isMilestone(roster?.index) ? (
|
{isMilestone(record.sessionNo) ? (
|
||||||
<Row className="g-2 mt-2">
|
<Row className="g-2 mt-2">
|
||||||
{ASSESSMENT_FIELDS.map(([key, label]) => (
|
{ASSESSMENT_FIELDS.map(([key, label]) => (
|
||||||
<Col md={4} key={key}>
|
<Col md={4} key={key}>
|
||||||
|
|
|
||||||
|
|
@ -124,7 +124,7 @@ export default function OnboardingPage() {
|
||||||
columnHelper.accessor('mobile', { header: 'Mobile' }),
|
columnHelper.accessor('mobile', { header: 'Mobile' }),
|
||||||
columnHelper.accessor((r) => r.cohort?.name || (r.matchingStatus === 'held' ? 'Held' : 'Pool'), { id: 'cohort', header: 'Cohort' }),
|
columnHelper.accessor((r) => r.cohort?.name || (r.matchingStatus === 'held' ? 'Held' : 'Pool'), { id: 'cohort', header: 'Cohort' }),
|
||||||
columnHelper.accessor((r) => r.diseaseType || '—', { id: 'diseaseType', header: 'Category' }),
|
columnHelper.accessor((r) => r.diseaseType || '—', { id: 'diseaseType', header: 'Category' }),
|
||||||
columnHelper.accessor((r) => formatDate(r.sessionDate), { id: 'sessionDate', header: 'Start date' }),
|
columnHelper.accessor((r) => formatDate(r.progress?.firstDate || r.sessionDate), { id: 'sessionDate', header: 'Start date' }),
|
||||||
columnHelper.accessor('slot', { header: 'Slot' }),
|
columnHelper.accessor('slot', { header: 'Slot' }),
|
||||||
columnHelper.accessor('paymentStatus', { header: 'Payment' }),
|
columnHelper.accessor('paymentStatus', { header: 'Payment' }),
|
||||||
columnHelper.accessor((r) => r.progress ? `${r.progress.attended}/${r.progress.total}` : '—', { id: 'progress', header: 'Sessions' }),
|
columnHelper.accessor((r) => r.progress ? `${r.progress.attended}/${r.progress.total}` : '—', { id: 'progress', header: 'Sessions' }),
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,13 @@ export default function ProgramSessionsPage() {
|
||||||
load().catch((err) => setError(err.message));
|
load().catch((err) => setError(err.message));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const openRoster = (session) => {
|
const openRoster = async (session) => {
|
||||||
setRoster(rosterFromSession(session));
|
setError('');
|
||||||
|
try {
|
||||||
|
setRoster(rosterFromSession(await api(`/api/cohort-sessions/${session.id}`)));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ export default function ReportsPage() {
|
||||||
<div className="d-flex gap-2 mb-3">
|
<div className="d-flex gap-2 mb-3">
|
||||||
<Button variant="outline-primary" onClick={() => download('patients')}>Export patients CSV</Button>
|
<Button variant="outline-primary" onClick={() => download('patients')}>Export patients CSV</Button>
|
||||||
<Button variant="outline-primary" onClick={() => download('utilization')}>Export utilization CSV</Button>
|
<Button variant="outline-primary" onClick={() => download('utilization')}>Export utilization CSV</Button>
|
||||||
|
<Button variant="outline-primary" onClick={() => download('vitals')}>Export vitals CSV</Button>
|
||||||
</div>
|
</div>
|
||||||
<Row className="g-3">
|
<Row className="g-3">
|
||||||
<Col lg={7}>
|
<Col lg={7}>
|
||||||
|
|
@ -49,17 +50,20 @@ export default function ReportsPage() {
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<h5 className="mb-3">Patient progress</h5>
|
<h5 className="mb-3">Patient progress</h5>
|
||||||
<Table responsive size="sm" className="mb-0">
|
<Table responsive size="sm" className="mb-0">
|
||||||
<thead><tr><th>Patient</th><th>Cohort</th><th>PRT</th><th>Attendance</th></tr></thead>
|
<thead><tr><th>Patient</th><th>Cohort</th><th>PRT</th><th>First session</th><th>Completed</th><th>Assigned</th><th>Attendance</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{patients.map((item) => (
|
{patients.map((item) => (
|
||||||
<tr key={item.id}>
|
<tr key={item.id}>
|
||||||
<td>{item.name}<div className="small text-muted">{item.diseaseType}</div></td>
|
<td>{item.name}<div className="small text-muted">{item.diseaseType}</div></td>
|
||||||
<td>{item.cohort?.name || '—'}</td>
|
<td>{item.cohort?.name || '—'}</td>
|
||||||
<td>{item.prt ? `${item.prt.firstName} ${item.prt.lastName}` : '—'}</td>
|
<td>{item.prt ? `${item.prt.firstName} ${item.prt.lastName}` : '—'}</td>
|
||||||
|
<td>{item.firstSession || '—'}</td>
|
||||||
|
<td>{item.completed}</td>
|
||||||
|
<td>{item.assigned}</td>
|
||||||
<td>{item.completion}</td>
|
<td>{item.completion}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{!patients.length ? <tr><td colSpan={4} className="text-muted">No confirmed patients.</td></tr> : null}
|
{!patients.length ? <tr><td colSpan={7} className="text-muted">No confirmed patients.</td></tr> : null}
|
||||||
</tbody>
|
</tbody>
|
||||||
</Table>
|
</Table>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue