VIrtual PR 4 Changes for move patients

This commit is contained in:
Abhijit 2026-11-07 22:09:53 +05:30
parent 2241886ca7
commit a0b2020286
13 changed files with 285 additions and 36 deletions

View File

@ -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));
}

View File

@ -34,6 +34,7 @@ const onboardedPatientSchema = new mongoose.Schema(
onboardedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
assignedEducator: { type: mongoose.Schema.Types.ObjectId, ref: 'User', 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: {
type: String,
enum: ['pending', 'confirmed', 'rejected'],

View File

@ -17,7 +17,7 @@ import { migratePatient } from '../services/matching.js';
import { notifyPaymentReview } from '../services/notify.js';
import { attendanceProgress, ensureSessionRecords, patientProgramStats } from '../services/programSessions.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);
function textField(payload, key) {
@ -208,11 +208,10 @@ function canReview(request) {
}
async function findAssignableCohort(cohortId, currentCohortId) {
const today = ymdToDate(toYmd(new Date()));
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);
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;
}
@ -287,7 +286,7 @@ export function onboardingRoutes(server) {
return {
items: items.map((item) => {
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,
page: Number(page),
@ -307,10 +306,9 @@ export function onboardingRoutes(server) {
path: '/api/onboarding/available-cohorts',
options: { pre: [{ method: requirePermission('onboarding', 'view') }] },
handler: async (request) => {
const today = ymdToDate(toYmd(new Date()));
const language = String(request.query.language || '').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;
const items = await Cohort.find(filter)
.populate('virtualPrt', 'firstName lastName email')

View File

@ -7,6 +7,7 @@ import { toCohortSessionDto } from '../utils/serialize.js';
import {
generateMeetsForDate,
listSessions,
patientSessionNumbers,
pushSession,
saveSessionRecords,
} from '../services/programSessions.js';
@ -104,7 +105,10 @@ export function programSessionRoutes(server) {
pre: [{ method: requirePermission('program_sessions', 'view') }],
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({
@ -194,7 +198,7 @@ export function programSessionRoutes(server) {
const saved = await saveSessionRecords(session, request.payload.records, { completed: request.payload.completed });
await saved.populate('records.patient', 'name mobile');
await saved.populate({ path: 'cohort', populate: { path: 'virtualPrt', select: 'firstName lastName email' } });
return toCohortSessionDto(saved);
return toCohortSessionDto(saved, await patientSessionNumbers([saved]));
},
});

View File

@ -35,6 +35,14 @@ function sessionMark(session, patientId) {
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) {
server.route({
method: 'GET',
@ -60,7 +68,7 @@ export function reportRoutes(server) {
const stats = await patientProgramStats(patients.map((item) => item._id));
return {
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);
return {
id: String(patient._id),
@ -73,6 +81,9 @@ export function reportRoutes(server) {
prt: patient.assignedEducator
? { id: String(patient.assignedEducator._id), firstName: patient.assignedEducator.firstName, lastName: patient.assignedEducator.lastName }
: null,
firstSession: progress.firstDate || '',
completed: progress.completed || 0,
assigned: progress.assigned || 0,
attended: progress.attended,
total: progress.total,
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]);
}
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 {
const patients = await listOnboardedForReports({ virtualPrtId: scopedPrt(request), diseaseType: request.query.diseaseType || undefined });
const cohortIds = [...new Set(patients.map((patient) => patient.cohort?._id).filter(Boolean))];
@ -214,12 +288,15 @@ export function reportRoutes(server) {
list.push(session);
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 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) => {
const pii = readOnboardedPii(patient);
const progress = stats.get(String(patient._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)));
while (marks.length < widest) marks.push(''); // shorter cohorts leave the extra session cells blank
return [
@ -229,6 +306,9 @@ export function reportRoutes(server) {
patient.cohort?.name || '',
patient.assignedEducator ? `${patient.assignedEducator.firstName} ${patient.assignedEducator.lastName}` : '',
patient.cohort?.startDate ? toYmd(patient.cohort.startDate) : '',
progress.firstDate || '',
progress.completed || 0,
progress.assigned || 0,
...marks,
];
})];

View File

@ -21,6 +21,7 @@ import { matchingRoutes } from './routes/matching.js';
import { programSessionRoutes } from './routes/programSessions.js';
import { reportRoutes } from './routes/reports.js';
import { startMeetingJobs } from './jobs/meetings.js';
import { startCohortExtensionJobs } from './jobs/cohortExtension.js';
import { startWhatsappWorker } from './services/whatsappQueue.js';
import { startMatchingJobs } from './jobs/matching.js';
import { startRetentionJobs } from './jobs/retention.js';
@ -107,6 +108,7 @@ export async function createServer() {
auditRoutes(server);
whatsappRoutes(server);
startMeetingJobs();
startCohortExtensionJobs();
startWhatsappWorker();
startMatchingJobs().catch((err) => console.error('[matching] schedule failed', err));
startRetentionJobs().catch((err) => console.error('[retention] schedule failed', err));

View File

@ -1,8 +1,10 @@
import { Cohort } from '../models/Cohort.js';
import { CohortSession } from '../models/CohortSession.js';
import { OnboardedPatient } from '../models/OnboardedPatient.js';
import { addPatientToWhatsapp, removePatientFromWhatsapp, syncWhatsappMembership } from './whatsapp.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';
async function safeWhatsapp(label, fn) {
@ -23,9 +25,17 @@ export async function syncCohortPatients(cohort, previousPatientIds = []) {
{ $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));
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 });
if (other) {
await OnboardedPatient.updateOne(
@ -52,13 +62,19 @@ export async function syncCohortPatients(cohort, previousPatientIds = []) {
export async function addPatientToCohort(cohort, patientId) {
const id = String(patientId);
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);
await cohort.save();
}
await OnboardedPatient.updateOne(
{ _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));
}
@ -66,6 +82,10 @@ export async function addPatientToCohort(cohort, patientId) {
export async function removePatientFromCohort(cohortId, patientId) {
if (!cohortId || !patientId) return;
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));
}

View File

@ -69,6 +69,27 @@ function manualSessionCount(cohort) {
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.
export async function persistCohortSessions(cohort) {
const settings = await getAppSettings();
@ -223,21 +244,32 @@ export async function rescheduleOpenSessions(cohort) {
return sessions;
}
// Books each patient onto the upcoming sessions they still have left. Finished sessions are not changed.
export async function ensureSessionRecords(cohort) {
const patients = patientIds(cohort).map(String);
if (!patients.length) return;
const sessions = await CohortSession.find({ cohort: cohort._id, status: { $in: ['scheduled', 'completed'] } });
for (const session of sessions) {
const have = new Set((session.records || []).map((record) => String(record.patient)));
let dirty = false;
for (const id of patients) {
if (!have.has(id)) {
const settings = await getAppSettings();
const length = await cohortProgramLength(cohort, settings);
const finished = await finishedSessionCounts(patients);
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) {
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: {} });
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.
@ -285,6 +317,37 @@ export async function nextAppendDate(cohort) {
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) {
const coverId = session.coverPrt?._id || session.coverPrt;
if (coverId) {
@ -322,6 +385,7 @@ export async function pushSession(session, reason, { notifyAdmins = false } = {}
htmlLink: session.htmlLink || '',
records: recordsFor(patientIds(cohort)),
});
await ensureSessionRecords(cohort);
const admins = notifyAdmins ? await adminUsers() : [];
await notifySessionPushed({
@ -621,10 +685,35 @@ export async function attendanceProgress(patientId) {
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 } = {}) {
const numbers = await patientSessionNumbers([session]);
const byPatient = new Map((session.records || []).map((record) => [String(record.patient._id || record.patient), record]));
for (const incoming of records) {
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,
};
}
if (incoming.assessment && (session.index === 15 || session.index === 30)) {
if (incoming.assessment && [15, 30].includes(numbers.get(`${session._id}:${id}`))) {
const previous = current.assessment || {};
current.assessment = {
fev1: incoming.assessment.fev1 ?? previous.fev1 ?? '',
@ -726,13 +815,15 @@ export async function moveSessionEvent(session, nextDate) {
export async function patientProgramStats(patientIds) {
const ids = patientIds.map((id) => String(id));
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 record of session.records || []) {
const id = String(record.patient);
const row = byPatient.get(id);
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.pre?.spo2 != null || record.post?.spo2 != null || record.pre?.pulse != null) {
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;
}

View File

@ -184,6 +184,7 @@ export function toOnboardedPatientDto(item) {
diseaseType: item.diseaseType || '',
preferredWeekday: item.preferredWeekday ?? null,
sessionDate: item.sessionDate || null,
cohortJoinedOn: item.cohortJoinedOn || '',
slot: item.slot || '',
matchingStatus: item.matchingStatus || 'unassigned',
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;
const cohort = session.cohort && typeof session.cohort === 'object' ? session.cohort : null;
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) },
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 },
post: { bp: record.post?.bp || '', spo2: record.post?.spo2 ?? null, pulse: record.post?.pulse ?? null },
assessment: {

View File

@ -46,6 +46,7 @@ export function rosterFromSession(session) {
patientId: record.patient?.id,
name: record.patient?.name || 'Patient',
attended: Boolean(record.attended),
sessionNo: record.patientSessionNo ?? null,
pre: { ...emptyVitals(), ...record.pre, spo2: record.pre?.spo2 ?? '', pulse: record.pre?.pulse ?? '' },
post: { ...emptyVitals(), ...record.post, spo2: record.post?.spo2 ?? '', pulse: record.post?.pulse ?? '' },
assessment: { ...emptyAssessment(), ...record.assessment },
@ -101,7 +102,7 @@ export default function SessionVitalsModal({ roster, onChange, onClose, onSaved
<Modal show={Boolean(roster)} onHide={onClose} size="lg">
<ModalHeader closeButton>
<h5 className="modal-title">
Attendance and vitals{isMilestone(roster?.index) ? ` · session ${roster.index}` : ''}
Attendance and vitals{roster?.index ? ` · cohort session ${roster.index}` : ''}
</h5>
</ModalHeader>
<ModalBody>
@ -109,7 +110,7 @@ export default function SessionVitalsModal({ roster, onChange, onClose, onSaved
{(roster?.records || []).length ? (roster.records || []).map((record, index) => (
<div key={record.patientId || index} className="border rounded p-3 mb-3">
<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
type="switch"
label="Attended"
@ -149,7 +150,7 @@ export default function SessionVitalsModal({ roster, onChange, onClose, onSaved
</Col>
))}
</Row>
{isMilestone(roster?.index) ? (
{isMilestone(record.sessionNo) ? (
<Row className="g-2 mt-2">
{ASSESSMENT_FIELDS.map(([key, label]) => (
<Col md={4} key={key}>

View File

@ -124,7 +124,7 @@ export default function OnboardingPage() {
columnHelper.accessor('mobile', { header: 'Mobile' }),
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) => 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('paymentStatus', { header: 'Payment' }),
columnHelper.accessor((r) => r.progress ? `${r.progress.attended}/${r.progress.total}` : '—', { id: 'progress', header: 'Sessions' }),

View File

@ -20,8 +20,13 @@ export default function ProgramSessionsPage() {
load().catch((err) => setError(err.message));
}, []);
const openRoster = (session) => {
setRoster(rosterFromSession(session));
const openRoster = async (session) => {
setError('');
try {
setRoster(rosterFromSession(await api(`/api/cohort-sessions/${session.id}`)));
} catch (err) {
setError(err.message);
}
};
return (

View File

@ -42,6 +42,7 @@ export default function ReportsPage() {
<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('utilization')}>Export utilization CSV</Button>
<Button variant="outline-primary" onClick={() => download('vitals')}>Export vitals CSV</Button>
</div>
<Row className="g-3">
<Col lg={7}>
@ -49,17 +50,20 @@ export default function ReportsPage() {
<CardBody>
<h5 className="mb-3">Patient progress</h5>
<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>
{patients.map((item) => (
<tr key={item.id}>
<td>{item.name}<div className="small text-muted">{item.diseaseType}</div></td>
<td>{item.cohort?.name || '—'}</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>
</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>
</Table>
</CardBody>