1. Cohort sessions can be saved with different schedule types.
2. Patient search is fixed. 3. Patient export now includes session attendance. 4. Updating a cohort time, slot, or type updates its sessions. 5. Maximum session count is increased. 6.Global Setting updated added new config of dynamich schedule 7.Capture at 15 and 30 seesion some diff vitals
This commit is contained in:
parent
c878aef1d4
commit
de373aaba3
|
|
@ -6,6 +6,7 @@ const cohortSchema = new mongoose.Schema(
|
|||
startDate: { type: Date, required: true },
|
||||
slot: { type: String, required: true, trim: true },
|
||||
language: { type: String, default: '', trim: true },
|
||||
scheduleId: { type: String, default: '', trim: true }, // blank means Normal; otherwise an extra schedule id
|
||||
diseaseType: { type: String, default: '', trim: true },
|
||||
status: {
|
||||
type: String,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ const assessmentSchema = new mongoose.Schema(
|
|||
eq5d3l: { type: String, default: '', trim: true },
|
||||
sgrq: { type: String, default: '', trim: true },
|
||||
sitToStand: { type: String, default: '', trim: true },
|
||||
rpeRest: { type: String, default: '', trim: true },
|
||||
rpeActivity: { type: String, default: '', trim: true },
|
||||
o2Regular: { type: String, default: '', trim: true },
|
||||
o2WithExercise: { type: String, default: '', trim: true },
|
||||
},
|
||||
|
|
|
|||
|
|
@ -50,6 +50,15 @@ const settingsSchema = new mongoose.Schema(
|
|||
matchingPriority: { type: [String], default: ['language', 'slot', 'weekday', 'disease'] },
|
||||
matchingCron: { type: String, default: '0 7 * * 6' },
|
||||
piiRetentionDays: { type: Number, default: 365 },
|
||||
cohortSchedules: { // extra schedules, each with its own weekdays and meeting count
|
||||
type: [{
|
||||
id: { type: String, required: true, trim: true },
|
||||
name: { type: String, required: true, trim: true },
|
||||
weekdays: { type: [Number], default: [] },
|
||||
upcomingSessionCount: { type: Number, default: 30 },
|
||||
}],
|
||||
default: [],
|
||||
},
|
||||
whatsappAdminIds: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }],
|
||||
whatsappReminderRules: { type: [reminderRuleSchema], default: [] },
|
||||
whatsappTaskGapMinMs: { type: Number, default: 15000 },
|
||||
|
|
|
|||
|
|
@ -6,10 +6,10 @@ import { Role } from '../models/Role.js';
|
|||
import { User } from '../models/User.js';
|
||||
import { OnboardedPatient } from '../models/OnboardedPatient.js';
|
||||
import { toCohortDto, userLanguages } from '../utils/serialize.js';
|
||||
import { assertCohortStartDate, buildCohortName, toYmd, upcomingSessionDates, ymdToDate } from '../services/schedule.js';
|
||||
import { assertCohortStartDate, buildCohortName, scheduleSessionCount, scheduleWeekdays, toYmd, upcomingSessionDates, ymdToDate } from '../services/schedule.js';
|
||||
import { releaseCohortPatients, syncCohortPatients } from '../services/cohorts.js';
|
||||
import { activateManualCohort } from '../services/matching.js';
|
||||
import { persistCohortSessions } from '../services/programSessions.js';
|
||||
import { ensureSessionRecords, persistCohortSessions, rescheduleOpenSessions } from '../services/programSessions.js';
|
||||
import { CohortSession } from '../models/CohortSession.js';
|
||||
import { assertConfiguredSlot, getAppSettings } from '../services/settings.js';
|
||||
import { toCohortSessionDto } from '../utils/serialize.js';
|
||||
|
|
@ -25,8 +25,18 @@ const cohortPayload = Joi.object({
|
|||
diseaseType: Joi.string().trim().allow('', null),
|
||||
patients: Joi.array().items(objectId).default([]),
|
||||
durationMinutes: Joi.number().min(15).max(180).optional(),
|
||||
scheduleId: Joi.string().trim().allow('', null),
|
||||
});
|
||||
|
||||
// Saved extra-schedule id to its weekdays. Blank means Normal.
|
||||
async function chosenWeekdays(scheduleId) {
|
||||
const id = String(scheduleId || '').trim();
|
||||
if (!id) return null;
|
||||
const days = await scheduleWeekdays(id);
|
||||
if (!days) throw Boom.badRequest('Select a schedule from Settings.');
|
||||
return days;
|
||||
}
|
||||
|
||||
async function assertVirtualPrt(userId) {
|
||||
const user = await User.findById(userId).populate('role');
|
||||
if (!user || !user.isActive) throw Boom.badRequest('Invalid Virtual PRT');
|
||||
|
|
@ -113,6 +123,7 @@ export function cohortRoutes(server) {
|
|||
},
|
||||
handler: async (request) => {
|
||||
const { startDate, slot, virtualPrt, patients } = request.payload;
|
||||
const scheduleId = String(request.payload.scheduleId || '').trim();
|
||||
const settings = await getAppSettings();
|
||||
const durationMinutes = request.payload.durationMinutes || settings.defaultDurationMinutes || 60;
|
||||
await assertConfiguredSlot(slot);
|
||||
|
|
@ -126,7 +137,7 @@ export function cohortRoutes(server) {
|
|||
throw Boom.badRequest(`A cohort can have at most ${settings.maxPatientsPerCohort || 10} patients.`);
|
||||
}
|
||||
try {
|
||||
await assertCohortStartDate(startDate, virtualPrt, language);
|
||||
await assertCohortStartDate(startDate, virtualPrt, language, await chosenWeekdays(scheduleId));
|
||||
} catch (err) {
|
||||
throw Boom.badRequest(err.message);
|
||||
}
|
||||
|
|
@ -136,6 +147,7 @@ export function cohortRoutes(server) {
|
|||
startDate: ymdToDate(startDate),
|
||||
slot,
|
||||
language,
|
||||
scheduleId,
|
||||
diseaseType: request.payload.diseaseType || '',
|
||||
status: 'active',
|
||||
virtualPrt,
|
||||
|
|
@ -168,14 +180,15 @@ export function cohortRoutes(server) {
|
|||
pre: [{ method: requirePermission('cohorts', 'view') }],
|
||||
validate: {
|
||||
params: Joi.object({ id: objectId.required() }),
|
||||
query: Joi.object({ count: Joi.number().min(1).max(40) }),
|
||||
query: Joi.object({ count: Joi.number().min(1).max(60) }),
|
||||
},
|
||||
},
|
||||
handler: async (request) => {
|
||||
const cohort = await findCohort(request);
|
||||
const stored = await CohortSession.find({ cohort: cohort._id }).sort({ index: 1 });
|
||||
if (stored.length) return { items: stored.map(toCohortSessionDto) };
|
||||
const dates = await upcomingSessionDates(toYmd(cohort.startDate), cohort.virtualPrt?._id || cohort.virtualPrt, request.query.count, cohort.language);
|
||||
const ownCount = await scheduleSessionCount(cohort.scheduleId);
|
||||
const dates = await upcomingSessionDates(toYmd(cohort.startDate), cohort.virtualPrt?._id || cohort.virtualPrt, ownCount || request.query.count, cohort.language, await scheduleWeekdays(cohort.scheduleId));
|
||||
return { items: dates.map((date) => ({ date, slot: cohort.slot, status: 'scheduled' })) };
|
||||
},
|
||||
});
|
||||
|
|
@ -196,7 +209,14 @@ export function cohortRoutes(server) {
|
|||
const previousName = cohort.name;
|
||||
const previousPrtId = String(cohort.virtualPrt?._id || cohort.virtualPrt || '');
|
||||
const hadGroup = Boolean(cohort.whatsappGroupJid);
|
||||
const previousStart = toYmd(cohort.startDate);
|
||||
const previousSchedule = String(cohort.scheduleId || '').trim();
|
||||
const previousSlot = String(cohort.slot || '');
|
||||
const { startDate, slot, virtualPrt, patients } = request.payload;
|
||||
const scheduleId = String(request.payload.scheduleId || '').trim();
|
||||
const scheduleChanged = previousStart !== String(startDate)
|
||||
|| previousSchedule !== scheduleId
|
||||
|| previousSlot !== String(slot || '');
|
||||
const settings = await getAppSettings();
|
||||
const durationMinutes = request.payload.durationMinutes || settings.defaultDurationMinutes || 60;
|
||||
await assertConfiguredSlot(slot);
|
||||
|
|
@ -210,7 +230,7 @@ export function cohortRoutes(server) {
|
|||
throw Boom.badRequest(`A cohort can have at most ${settings.maxPatientsPerCohort || 10} patients.`);
|
||||
}
|
||||
try {
|
||||
await assertCohortStartDate(startDate, virtualPrt, language);
|
||||
await assertCohortStartDate(startDate, virtualPrt, language, await chosenWeekdays(scheduleId));
|
||||
} catch (err) {
|
||||
throw Boom.badRequest(err.message);
|
||||
}
|
||||
|
|
@ -218,6 +238,7 @@ export function cohortRoutes(server) {
|
|||
cohort.startDate = ymdToDate(startDate);
|
||||
cohort.slot = slot;
|
||||
cohort.language = language;
|
||||
cohort.scheduleId = scheduleId;
|
||||
cohort.diseaseType = request.payload.diseaseType || cohort.diseaseType || '';
|
||||
cohort.virtualPrt = virtualPrt;
|
||||
cohort.patients = patients;
|
||||
|
|
@ -229,7 +250,9 @@ export function cohortRoutes(server) {
|
|||
await onCohortUpdated(cohort._id, { previousName, previousPrtId, hadGroup }).catch((err) => {
|
||||
console.error(`[whatsapp] cohort update failed: ${err?.message || err}`);
|
||||
});
|
||||
await persistCohortSessions(cohort);
|
||||
if (scheduleChanged) await rescheduleOpenSessions(cohort);
|
||||
else if (await CohortSession.countDocuments({ cohort: cohort._id })) await ensureSessionRecords(cohort);
|
||||
else await persistCohortSessions(cohort);
|
||||
const ready = await Cohort.findById(cohort._id)
|
||||
.populate('virtualPrt', 'firstName lastName email')
|
||||
.populate({ path: 'patients', match: { reviewStatus: 'confirmed' }, select: 'name mobile whatsappOptOut' });
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
resolveReassignment,
|
||||
retargetProposal,
|
||||
runMatching,
|
||||
setProposalSchedule,
|
||||
} from '../services/matching.js';
|
||||
|
||||
const objectId = Joi.string().hex().length(24);
|
||||
|
|
@ -136,6 +137,21 @@ export function matchingRoutes(server) {
|
|||
},
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: 'PATCH',
|
||||
path: '/api/matching/proposals/{id}/schedule',
|
||||
options: {
|
||||
pre: [{ method: requirePermission('matching', 'edit') }],
|
||||
validate: {
|
||||
params: Joi.object({ id: objectId.required() }),
|
||||
payload: Joi.object({
|
||||
scheduleId: Joi.string().trim().allow('').required(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
handler: async (request) => setProposalSchedule(request.params.id, request.payload.scheduleId),
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: 'POST',
|
||||
path: '/api/matching/proposals/{id}/confirm',
|
||||
|
|
|
|||
|
|
@ -262,10 +262,11 @@ export function onboardingRoutes(server) {
|
|||
const searchFilter = {};
|
||||
if (search) {
|
||||
const term = String(search).trim();
|
||||
const hashes = [hashMobile(term), hashName(term)].filter(Boolean);
|
||||
const mobileHash = hashMobile(term);
|
||||
const nameHash = hashName(term);
|
||||
searchFilter.$or = [];
|
||||
if (hashes[0]) searchFilter.$or.push({ mobileHash: hashes[0] });
|
||||
if (hashes[1]) searchFilter.$or.push({ nameHash: hashes[1] });
|
||||
if (mobileHash) searchFilter.$or.push({ mobileHash });
|
||||
if (nameHash) searchFilter.$or.push({ nameHash });
|
||||
if (!searchFilter.$or.length) searchFilter.$or.push({ _id: null });
|
||||
}
|
||||
const filter = mergeFilters(await onboardingScopeFilter(request), extra, searchFilter);
|
||||
|
|
|
|||
|
|
@ -179,6 +179,8 @@ export function programSessionRoutes(server) {
|
|||
eq5d3l: Joi.string().allow('', null),
|
||||
sgrq: Joi.string().allow('', null),
|
||||
sitToStand: Joi.string().allow('', null),
|
||||
rpeRest: Joi.string().allow('', null),
|
||||
rpeActivity: Joi.string().allow('', null),
|
||||
o2Regular: Joi.string().allow('', null),
|
||||
o2WithExercise: Joi.string().allow('', null),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -29,6 +29,12 @@ function toCsv(rows) {
|
|||
return rows.map((row) => row.map(csvEscape).join(',')).join('\n');
|
||||
}
|
||||
|
||||
// P when that patient attended the meeting, A when they did not.
|
||||
function sessionMark(session, patientId) {
|
||||
const record = (session.records || []).find((item) => String(item.patient?._id || item.patient) === patientId);
|
||||
return record?.attended ? 'P' : 'A';
|
||||
}
|
||||
|
||||
export function reportRoutes(server) {
|
||||
server.route({
|
||||
method: 'GET',
|
||||
|
|
@ -195,17 +201,35 @@ export function reportRoutes(server) {
|
|||
rows = [['PRT', 'Used', 'Max', 'Idle', 'Sessions', 'Cancelled'], ...items];
|
||||
} else {
|
||||
const patients = await listOnboardedForReports({ virtualPrtId: scopedPrt(request), diseaseType: request.query.diseaseType || undefined });
|
||||
const stats = await patientProgramStats(patients.map((item) => item._id));
|
||||
rows = [['Name', 'Mobile', 'Disease', 'Cohort', 'PRT', 'Attendance'], ...patients.map((patient) => {
|
||||
const progress = stats.get(String(patient._id)) || { attended: 0, total: 0 };
|
||||
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')
|
||||
.sort({ index: 1, date: 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 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}`)];
|
||||
rows = [headers, ...patients.map((patient) => {
|
||||
const pii = readOnboardedPii(patient);
|
||||
const cohortId = patient.cohort?._id ? String(patient.cohort._id) : '';
|
||||
const meetings = cohortId ? (byCohort.get(cohortId) || []) : [];
|
||||
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 [
|
||||
pii.name,
|
||||
pii.mobile,
|
||||
patient.diseaseType || '',
|
||||
patient.cohort?.name || '',
|
||||
patient.assignedEducator ? `${patient.assignedEducator.firstName} ${patient.assignedEducator.lastName}` : '',
|
||||
`${progress.attended}/${progress.total}`,
|
||||
patient.cohort?.startDate ? toYmd(patient.cohort.startDate) : '',
|
||||
...marks,
|
||||
];
|
||||
})];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import Joi from 'joi';
|
||||
import Boom from '@hapi/boom';
|
||||
import { credentials } from '../plugins/auth.js';
|
||||
import { Cohort } from '../models/Cohort.js';
|
||||
import { getAppSettings, normalizeSettingsPayload, toSettingsDto } from '../services/settings.js';
|
||||
|
||||
function requireSuperAdmin(request, h) {
|
||||
|
|
@ -17,7 +18,7 @@ const settingsPayload = Joi.object({
|
|||
slotDurationMinutes: Joi.number().min(15).max(180).required(),
|
||||
cohortStartWeekdays: Joi.array().items(Joi.number().integer().min(0).max(6)).min(1).required(),
|
||||
repeatIntervalDays: Joi.number().integer().min(1).max(30).required(),
|
||||
upcomingSessionCount: Joi.number().integer().min(1).max(40).required(),
|
||||
upcomingSessionCount: Joi.number().integer().min(1).max(60).required(),
|
||||
defaultDurationMinutes: Joi.number().min(15).max(180).required(),
|
||||
skipHolidays: Joi.boolean().required(),
|
||||
skipPrtLeave: Joi.boolean().required(),
|
||||
|
|
@ -30,6 +31,12 @@ const settingsPayload = Joi.object({
|
|||
matchingPriority: Joi.array().items(Joi.string().trim().min(1)).min(1).required(),
|
||||
matchingCron: Joi.string().trim().min(1).required(),
|
||||
piiRetentionDays: Joi.number().integer().min(1).max(3650).optional(),
|
||||
cohortSchedules: Joi.array().items(Joi.object({
|
||||
id: Joi.string().trim().allow('', null),
|
||||
name: Joi.string().trim().min(1).required(),
|
||||
weekdays: Joi.array().items(Joi.number().integer().min(0).max(6)).min(1).required(),
|
||||
upcomingSessionCount: Joi.number().integer().min(1).max(60).required(),
|
||||
})).default([]),
|
||||
});
|
||||
|
||||
export function settingsRoutes(server) {
|
||||
|
|
@ -48,7 +55,15 @@ export function settingsRoutes(server) {
|
|||
},
|
||||
handler: async (request) => {
|
||||
const settings = await getAppSettings();
|
||||
Object.assign(settings, normalizeSettingsPayload(request.payload));
|
||||
const next = normalizeSettingsPayload(request.payload);
|
||||
const removed = (settings.cohortSchedules || [])
|
||||
.map((row) => row.id)
|
||||
.filter((id) => !next.cohortSchedules.some((row) => row.id === id));
|
||||
if (removed.length) {
|
||||
const used = await Cohort.countDocuments({ scheduleId: { $in: removed } });
|
||||
if (used) throw Boom.badRequest('A cohort is still using a schedule you removed.');
|
||||
}
|
||||
Object.assign(settings, next);
|
||||
await settings.save();
|
||||
return toSettingsDto(settings);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -234,12 +234,14 @@ export async function createCalendarEvent(user, {
|
|||
}
|
||||
}
|
||||
|
||||
// Updates an existing Google event and keeps the same Meet link. No new invite.
|
||||
export async function updateCalendarEvent(user, eventId, {
|
||||
title,
|
||||
start,
|
||||
end,
|
||||
timeZone,
|
||||
description = '',
|
||||
recurrence = null,
|
||||
}) {
|
||||
if (!eventId) return null;
|
||||
if (!user.google?.refreshToken && !user.google?.accessToken) {
|
||||
|
|
@ -247,16 +249,19 @@ export async function updateCalendarEvent(user, eventId, {
|
|||
}
|
||||
const auth = oauthClientFor(user);
|
||||
const calendar = google.calendar({ version: 'v3', auth });
|
||||
const requestBody = {
|
||||
summary: title,
|
||||
description: description || undefined,
|
||||
start: { dateTime: start, timeZone },
|
||||
end: { dateTime: end, timeZone },
|
||||
};
|
||||
if (Array.isArray(recurrence) && recurrence.length) requestBody.recurrence = recurrence;
|
||||
try {
|
||||
const { data } = await calendar.events.patch({
|
||||
calendarId: 'primary',
|
||||
eventId,
|
||||
requestBody: {
|
||||
summary: title,
|
||||
description: description || undefined,
|
||||
start: { dateTime: start, timeZone },
|
||||
end: { dateTime: end, timeZone },
|
||||
},
|
||||
sendUpdates: 'none',
|
||||
requestBody,
|
||||
});
|
||||
return {
|
||||
id: data.id,
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { User } from '../models/User.js';
|
|||
import { addPatientToCohort, removePatientFromCohort, syncCohortPatients } from './cohorts.js';
|
||||
import { notifyCohortConfirmed, notifyReassignment } from './notify.js';
|
||||
import { persistCohortSessions } from './programSessions.js';
|
||||
import { addDays, assertCohortStartDate, buildCohortName, toYmd, todayYmd, weekdayOf, ymdToDate } from './schedule.js';
|
||||
import { addDays, assertCohortStartDate, buildCohortName, scheduleWeekdays, toYmd, todayYmd, upcomingSessionDates, weekdayOf, ymdToDate } from './schedule.js';
|
||||
import { assertConfiguredSlot, getAppSettings, slotLabel, WEEKDAY_LABELS } from './settings.js';
|
||||
import { readOnboardedPii } from './cryptoPii.js';
|
||||
import { toCohortDto, userLanguages } from '../utils/serialize.js';
|
||||
|
|
@ -425,12 +425,45 @@ async function fieldNoticesForCohort(cohort) {
|
|||
return [...seen.values()];
|
||||
}
|
||||
|
||||
// Sets the schedule on a proposed cohort and moves the start if that weekday is not allowed.
|
||||
export async function setProposalSchedule(proposalId, scheduleId) {
|
||||
const cohort = await Cohort.findById(proposalId);
|
||||
if (!cohort) throw Boom.notFound('Proposed cohort not found');
|
||||
if (cohort.status !== 'proposed') throw Boom.badRequest('Only a proposed cohort can change schedule.');
|
||||
const id = String(scheduleId || '').trim();
|
||||
const weekdays = id ? await scheduleWeekdays(id) : null;
|
||||
if (id && !weekdays) throw Boom.badRequest('Select a schedule from Settings.');
|
||||
cohort.scheduleId = id;
|
||||
if (weekdays && !weekdays.includes(weekdayOf(toYmd(cohort.startDate)))) {
|
||||
const dates = await upcomingSessionDates(toYmd(cohort.startDate), cohort.virtualPrt, 1, cohort.language, weekdays);
|
||||
if (!dates[0]) throw Boom.badRequest('No date matches this schedule.');
|
||||
cohort.startDate = ymdToDate(dates[0]);
|
||||
cohort.name = buildCohortName({ slot: cohort.slot, language: cohort.language, startDate: dates[0] });
|
||||
}
|
||||
await cohort.save();
|
||||
await OnboardedPatient.updateMany({ cohort: cohort._id }, { $set: { sessionDate: cohort.startDate } });
|
||||
const ready = await Cohort.findById(cohort._id)
|
||||
.populate('virtualPrt', 'firstName lastName email')
|
||||
.populate({ path: 'patients', select: 'name mobile whatsappOptOut' });
|
||||
return toCohortDto(ready);
|
||||
}
|
||||
|
||||
// Confirms a proposal, moves a Sunday or past start onto a valid day, then creates sessions.
|
||||
export async function confirmCohort(cohortId) {
|
||||
const cohort = await Cohort.findById(cohortId).populate('virtualPrt').populate('patients');
|
||||
if (!cohort) throw Boom.notFound('Cohort not found');
|
||||
if (cohort.status !== 'proposed') throw Boom.badRequest('Only proposed cohorts can be confirmed from matching.');
|
||||
const startYmd = toYmd(cohort.startDate);
|
||||
if (startYmd < todayYmd(env.APP_TIMEZONE)) {
|
||||
const weekdays = await scheduleWeekdays(cohort.scheduleId);
|
||||
const today = todayYmd(env.APP_TIMEZONE);
|
||||
const startsOnSunday = weekdayOf(startYmd) === 0;
|
||||
if (startsOnSunday || (weekdays && startYmd < today)) {
|
||||
const from = startYmd < today ? today : startYmd;
|
||||
const dates = await upcomingSessionDates(from, cohort.virtualPrt?._id || cohort.virtualPrt, 1, cohort.language, weekdays);
|
||||
if (!dates[0]) throw Boom.badRequest('No upcoming date matches this schedule.');
|
||||
cohort.startDate = ymdToDate(dates[0]);
|
||||
cohort.name = buildCohortName({ slot: cohort.slot, language: cohort.language, startDate: dates[0] });
|
||||
} else if (startYmd < today) {
|
||||
cohort.startDate = ymdToDate(nextStartDateForWeekday(weekdayOf(startYmd)));
|
||||
cohort.name = buildCohortName({ slot: cohort.slot, language: cohort.language, startDate: toYmd(cohort.startDate) });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ import {
|
|||
toYmd,
|
||||
todayYmd,
|
||||
upcomingSessionDates,
|
||||
scheduleSessionCount,
|
||||
scheduleWeekdays,
|
||||
weekdayOf,
|
||||
} from './schedule.js';
|
||||
import { getAppSettings } from './settings.js';
|
||||
import { toCohortSessionDto } from '../utils/serialize.js';
|
||||
|
|
@ -60,9 +63,12 @@ function recordsFor(ids) {
|
|||
return ids.map((patient) => ({ patient, attended: false, pre: {}, post: {} }));
|
||||
}
|
||||
|
||||
// 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();
|
||||
const needed = Math.min(Math.max(Number(settings.upcomingSessionCount) || 30, 1), 40);
|
||||
const ownCount = await scheduleSessionCount(cohort.scheduleId);
|
||||
const needed = Math.min(Math.max(Number(ownCount || settings.upcomingSessionCount) || 30, 1), 60);
|
||||
const weekdays = await scheduleWeekdays(cohort.scheduleId);
|
||||
const existing = await CohortSession.find({ cohort: cohort._id }).sort({ index: 1, date: 1 });
|
||||
const active = existing.filter((session) => session.status !== 'cancelled');
|
||||
const patients = patientIds(cohort);
|
||||
|
|
@ -73,6 +79,7 @@ export async function persistCohortSessions(cohort) {
|
|||
cohort.virtualPrt?._id || cohort.virtualPrt,
|
||||
needed,
|
||||
cohort.language,
|
||||
weekdays,
|
||||
);
|
||||
if (!dates.length) return [];
|
||||
await CohortSession.insertMany(dates.map((date, index) => ({
|
||||
|
|
@ -105,6 +112,111 @@ export async function persistCohortSessions(cohort) {
|
|||
return CohortSession.find({ cohort: cohort._id }).sort({ index: 1 });
|
||||
}
|
||||
|
||||
// Next dates from today or the new start, skipping Sunday, holiday, leave, and completed dates.
|
||||
async function collectSessionDates(from, cohort, count, weekdays, blocked) {
|
||||
const dates = [];
|
||||
const prtId = cohort.virtualPrt?._id || cohort.virtualPrt;
|
||||
let cursor = from;
|
||||
for (let guard = 0; dates.length < count && guard < 8; guard += 1) {
|
||||
const batch = await upcomingSessionDates(
|
||||
cursor,
|
||||
prtId,
|
||||
Math.min(60, count - dates.length + blocked.size + 5),
|
||||
cohort.language,
|
||||
weekdays,
|
||||
);
|
||||
if (!batch.length) break;
|
||||
for (const date of batch) {
|
||||
if (date < from || blocked.has(date) || dates.includes(date)) continue;
|
||||
dates.push(date);
|
||||
if (dates.length >= count) break;
|
||||
}
|
||||
const next = addDays(batch[batch.length - 1], 1);
|
||||
if (next <= cursor) break;
|
||||
cursor = next;
|
||||
}
|
||||
return dates.slice(0, count);
|
||||
}
|
||||
|
||||
// Moves the existing Google event onto the new dates. The Meet link stays.
|
||||
async function syncExistingMeetSeries(cohort, sessions) {
|
||||
const linked = sessions.find((session) => session.googleEventId);
|
||||
if (!linked) return;
|
||||
const open = sessions
|
||||
.filter((session) => session.status !== 'completed')
|
||||
.sort((a, b) => String(a.date).localeCompare(String(b.date)));
|
||||
if (!open.length) return;
|
||||
const host = await sessionHost(linked, cohort);
|
||||
if (!host?.google?.refreshToken && !host?.google?.accessToken) return;
|
||||
const settings = await getAppSettings();
|
||||
const weekdays = await scheduleWeekdays(cohort.scheduleId);
|
||||
const first = open[0];
|
||||
const last = open[open.length - 1];
|
||||
const times = slotDateTimes(first.date, first.slot || cohort.slot, cohort.durationMinutes || settings.defaultDurationMinutes || 60);
|
||||
try {
|
||||
await updateCalendarEvent(host, linked.googleEventId, {
|
||||
title: `PR Session · ${cohort.name}`,
|
||||
description: `Pulmonary rehab repeat meetings for ${cohort.name} (${open.length} upcoming sessions).`,
|
||||
start: times.start,
|
||||
end: times.end,
|
||||
timeZone: env.APP_TIMEZONE,
|
||||
recurrence: sessionRecurrence({
|
||||
firstDate: first.date,
|
||||
lastDate: last.date,
|
||||
interval: settings.repeatIntervalDays,
|
||||
startTime: times.startTime,
|
||||
timeZone: env.APP_TIMEZONE,
|
||||
includeDates: open.map((session) => session.date),
|
||||
weekdays,
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[meet] series update failed: ${err?.message || err}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Replaces sessions that are not completed. Completed sessions stay.
|
||||
export async function rescheduleOpenSessions(cohort) {
|
||||
const settings = await getAppSettings();
|
||||
const ownCount = await scheduleSessionCount(cohort.scheduleId);
|
||||
const cap = Math.min(Math.max(Number(ownCount || settings.upcomingSessionCount) || 30, 1), 60);
|
||||
const weekdays = await scheduleWeekdays(cohort.scheduleId);
|
||||
const existing = await CohortSession.find({ cohort: cohort._id }).sort({ index: 1, date: 1 });
|
||||
const completed = existing.filter((session) => session.status === 'completed');
|
||||
const open = existing.filter((session) => session.status !== 'completed');
|
||||
const remaining = Math.max(0, cap - completed.length);
|
||||
const today = todayYmd(env.APP_TIMEZONE);
|
||||
const startYmd = toYmd(cohort.startDate);
|
||||
const from = startYmd >= today ? startYmd : today;
|
||||
const blocked = new Set(completed.map((session) => session.date));
|
||||
const dates = remaining
|
||||
? await collectSessionDates(from, cohort, remaining, weekdays, blocked)
|
||||
: [];
|
||||
const link = existing.find((session) => session.googleEventId) || {};
|
||||
if (open.length) await CohortSession.deleteMany({ _id: { $in: open.map((session) => session._id) } });
|
||||
let index = completed.reduce((max, session) => Math.max(max, session.index || 0), 0);
|
||||
if (dates.length) {
|
||||
await CohortSession.insertMany(dates.map((date) => {
|
||||
index += 1;
|
||||
return {
|
||||
cohort: cohort._id,
|
||||
index,
|
||||
date,
|
||||
slot: cohort.slot,
|
||||
status: 'scheduled',
|
||||
googleEventId: link.googleEventId || '',
|
||||
meetUrl: link.meetUrl || '',
|
||||
htmlLink: link.htmlLink || '',
|
||||
records: recordsFor(patientIds(cohort)),
|
||||
};
|
||||
}));
|
||||
}
|
||||
await ensureSessionRecords(cohort);
|
||||
const sessions = await CohortSession.find({ cohort: cohort._id }).sort({ index: 1 });
|
||||
await syncExistingMeetSeries(cohort, sessions);
|
||||
return sessions;
|
||||
}
|
||||
|
||||
export async function ensureSessionRecords(cohort) {
|
||||
const patients = patientIds(cohort).map(String);
|
||||
if (!patients.length) return;
|
||||
|
|
@ -122,7 +234,32 @@ export async function ensureSessionRecords(cohort) {
|
|||
}
|
||||
}
|
||||
|
||||
// Next date for an extra schedule, skipping unticked days, Sunday, holiday, and leave.
|
||||
async function nextWeekdayAppendDate(cohort, weekdays) {
|
||||
const settings = await getAppSettings();
|
||||
const allowed = new Set(weekdays.map(Number));
|
||||
const last = await CohortSession.findOne({ cohort: cohort._id }).sort({ date: -1, index: -1 });
|
||||
let cursor = last?.date ? addDays(last.date, 1) : toYmd(cohort.startDate);
|
||||
const horizon = addDays(cursor, 80);
|
||||
const holidays = settings.skipHolidays !== false
|
||||
? await holidayDatesBetween(cursor, horizon, cohort.language)
|
||||
: new Set();
|
||||
const prtId = cohort.virtualPrt?._id || cohort.virtualPrt;
|
||||
for (let guard = 0; guard < 400; guard += 1) {
|
||||
const weekdayBlocked = !allowed.has(weekdayOf(cursor));
|
||||
const sundayBlocked = weekdayOf(cursor) === 0;
|
||||
const holidayBlocked = settings.skipHolidays !== false && holidays.has(cursor);
|
||||
const leaveBlocked = settings.skipPrtLeave !== false && await leaveCoversDate(prtId, cursor);
|
||||
if (!weekdayBlocked && !sundayBlocked && !holidayBlocked && !leaveBlocked) return cursor;
|
||||
cursor = addDays(cursor, 1);
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
// Next date after the last session. Extra schedules use weekdays. Normal uses the interval.
|
||||
export async function nextAppendDate(cohort) {
|
||||
const weekdays = await scheduleWeekdays(cohort.scheduleId);
|
||||
if (weekdays) return nextWeekdayAppendDate(cohort, weekdays);
|
||||
const settings = await getAppSettings();
|
||||
const interval = Math.max(1, Number(settings.repeatIntervalDays) || 2);
|
||||
const last = await CohortSession.findOne({ cohort: cohort._id }).sort({ date: -1, index: -1 });
|
||||
|
|
@ -133,9 +270,10 @@ export async function nextAppendDate(cohort) {
|
|||
: new Set();
|
||||
const prtId = cohort.virtualPrt?._id || cohort.virtualPrt;
|
||||
for (let guard = 0; guard < 400; guard += 1) {
|
||||
const sundayBlocked = weekdayOf(cursor) === 0;
|
||||
const holidayBlocked = settings.skipHolidays !== false && holidays.has(cursor);
|
||||
const leaveBlocked = settings.skipPrtLeave !== false && await leaveCoversDate(prtId, cursor);
|
||||
if (!holidayBlocked && !leaveBlocked) return cursor;
|
||||
if (!sundayBlocked && !holidayBlocked && !leaveBlocked) return cursor;
|
||||
cursor = addDays(cursor, interval);
|
||||
}
|
||||
return cursor;
|
||||
|
|
@ -273,6 +411,7 @@ export async function ensureMeetForCohort(cohort) {
|
|||
}
|
||||
|
||||
const settings = await getAppSettings();
|
||||
const weekdays = await scheduleWeekdays(cohort.scheduleId);
|
||||
const first = sessions[0];
|
||||
const last = sessions[sessions.length - 1];
|
||||
const times = slotDateTimes(first.date, first.slot || cohort.slot, cohort.durationMinutes || settings.defaultDurationMinutes || 60);
|
||||
|
|
@ -292,6 +431,7 @@ export async function ensureMeetForCohort(cohort) {
|
|||
startTime: times.startTime,
|
||||
timeZone: env.APP_TIMEZONE,
|
||||
includeDates: sessions.map((session) => session.date),
|
||||
weekdays,
|
||||
}),
|
||||
});
|
||||
await applyMeetToSessions(sessions, event, first.slot || cohort.slot);
|
||||
|
|
@ -478,6 +618,8 @@ export async function saveSessionRecords(session, records = [], { completed } =
|
|||
eq5d3l: incoming.assessment.eq5d3l ?? previous.eq5d3l ?? '',
|
||||
sgrq: incoming.assessment.sgrq ?? previous.sgrq ?? '',
|
||||
sitToStand: incoming.assessment.sitToStand ?? previous.sitToStand ?? '',
|
||||
rpeRest: incoming.assessment.rpeRest ?? previous.rpeRest ?? '',
|
||||
rpeActivity: incoming.assessment.rpeActivity ?? previous.rpeActivity ?? '',
|
||||
o2Regular: incoming.assessment.o2Regular ?? previous.o2Regular ?? '',
|
||||
o2WithExercise: incoming.assessment.o2WithExercise ?? previous.o2WithExercise ?? '',
|
||||
};
|
||||
|
|
@ -575,7 +717,7 @@ export async function listOnboardedForReports({ virtualPrtId, diseaseType, cohor
|
|||
if (diseaseType) filter.diseaseType = diseaseType;
|
||||
if (cohortId) filter.cohort = cohortId;
|
||||
return OnboardedPatient.find(filter)
|
||||
.populate('cohort', 'name slot language diseaseType virtualPrt')
|
||||
.populate('cohort', 'name slot language diseaseType virtualPrt startDate')
|
||||
.populate('assignedEducator', 'firstName lastName email')
|
||||
.populate('onboardedBy', 'firstName lastName email')
|
||||
.sort({ name: 1 });
|
||||
|
|
|
|||
|
|
@ -73,9 +73,38 @@ export async function leaveCoversDate(userId, ymd) {
|
|||
return Boolean(leave);
|
||||
}
|
||||
|
||||
export async function assertCohortStartDate(startYmd, virtualPrtId, language) {
|
||||
// Weekdays for an extra schedule. Blank id means Normal, which uses the repeat interval.
|
||||
export async function scheduleWeekdays(scheduleId) {
|
||||
const id = String(scheduleId || '').trim();
|
||||
if (!id) return null;
|
||||
const settings = await getAppSettings();
|
||||
if (!isAllowedStartWeekday(startYmd, settings.cohortStartWeekdays)) {
|
||||
const row = (settings.cohortSchedules || []).find((item) => item.id === id);
|
||||
if (!row) return null;
|
||||
const days = [...new Set((row.weekdays || []).map(Number))].filter((day) => day >= 1 && day <= 6);
|
||||
return days.length ? days : null;
|
||||
}
|
||||
|
||||
// Meeting cap for an extra schedule. Blank id uses the Normal meetings-per-cohort number.
|
||||
export async function scheduleSessionCount(scheduleId) {
|
||||
const id = String(scheduleId || '').trim();
|
||||
if (!id) return null;
|
||||
const settings = await getAppSettings();
|
||||
const row = (settings.cohortSchedules || []).find((item) => item.id === id);
|
||||
const count = Number(row?.upcomingSessionCount);
|
||||
if (!Number.isFinite(count) || count < 1) return null;
|
||||
return Math.min(Math.round(count), 60);
|
||||
}
|
||||
|
||||
// Rejects Sunday, a holiday, leave, or a weekday this schedule does not meet.
|
||||
export async function assertCohortStartDate(startYmd, virtualPrtId, language, weekdays = null) {
|
||||
if (weekdayOf(startYmd) === 0) throw new Error('A cohort cannot meet on Sunday.');
|
||||
const settings = await getAppSettings();
|
||||
const allowed = Array.isArray(weekdays) && weekdays.length ? weekdays.map(Number).filter((day) => day !== 0) : null;
|
||||
if (allowed) {
|
||||
if (!allowed.includes(weekdayOf(startYmd))) {
|
||||
throw new Error(`This schedule only meets on ${formatWeekdays(allowed)}.`);
|
||||
}
|
||||
} else if (!isAllowedStartWeekday(startYmd, settings.cohortStartWeekdays)) {
|
||||
throw new Error(`A new cohort can only start on ${formatWeekdays(settings.cohortStartWeekdays)}.`);
|
||||
}
|
||||
if (settings.skipHolidays !== false) {
|
||||
|
|
@ -119,8 +148,10 @@ export function slotDateTimes(date, slot, durationMinutes = 60) {
|
|||
return { startTime, endTime, start, end };
|
||||
}
|
||||
|
||||
// True when the date is on the Normal interval and is not Sunday, a holiday, or leave.
|
||||
export async function isCohortSessionOn(startYmd, virtualPrtId, targetYmd, language) {
|
||||
if (!startYmd || !targetYmd || targetYmd < startYmd) return false;
|
||||
if (weekdayOf(targetYmd) === 0) return false;
|
||||
const settings = await getAppSettings();
|
||||
const interval = Math.max(1, Number(settings.repeatIntervalDays) || 2);
|
||||
const start = parseYmd(startYmd);
|
||||
|
|
@ -135,39 +166,63 @@ export async function isCohortSessionOn(startYmd, virtualPrtId, targetYmd, langu
|
|||
return true;
|
||||
}
|
||||
|
||||
export async function upcomingSessionDates(startYmd, virtualPrtId, count, language) {
|
||||
const RRULE_DAYS = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'];
|
||||
|
||||
// Builds meeting dates. Normal uses the interval. An extra schedule uses its weekdays. Sunday, holiday, and leave are skipped.
|
||||
export async function upcomingSessionDates(startYmd, virtualPrtId, count, language, weekdays = null) {
|
||||
const settings = await getAppSettings();
|
||||
const needed = Math.min(Math.max(Number(count) || settings.upcomingSessionCount || 30, 1), 40);
|
||||
const interval = Math.max(1, Number(settings.repeatIntervalDays) || 2);
|
||||
const needed = Math.min(Math.max(Number(count) || settings.upcomingSessionCount || 30, 1), 60);
|
||||
const allowed = Array.isArray(weekdays) && weekdays.length ? new Set(weekdays.map(Number)) : null;
|
||||
const interval = allowed ? 1 : Math.max(1, Number(settings.repeatIntervalDays) || 2);
|
||||
const horizon = addDays(startYmd, needed * interval * 8);
|
||||
const holidays = settings.skipHolidays !== false ? await holidayDatesBetween(startYmd, horizon, language) : new Set();
|
||||
const dates = [];
|
||||
let cursor = startYmd;
|
||||
let guard = 0;
|
||||
while (dates.length < needed && guard < 400) {
|
||||
const sundayBlocked = weekdayOf(cursor) === 0;
|
||||
const weekdayBlocked = allowed ? !allowed.has(weekdayOf(cursor)) : false;
|
||||
const holidayBlocked = settings.skipHolidays !== false && holidays.has(cursor);
|
||||
const leaveBlocked = settings.skipPrtLeave !== false && await leaveCoversDate(virtualPrtId, cursor);
|
||||
if (!holidayBlocked && !leaveBlocked) dates.push(cursor);
|
||||
if (!weekdayBlocked && !sundayBlocked && !holidayBlocked && !leaveBlocked) dates.push(cursor);
|
||||
cursor = addDays(cursor, interval);
|
||||
guard += 1;
|
||||
}
|
||||
return dates;
|
||||
}
|
||||
|
||||
export function sessionRecurrence({ firstDate, lastDate, interval, startTime, timeZone, includeDates }) {
|
||||
// Google repeat rule. Sunday is excluded. An extra schedule repeats weekly on its days.
|
||||
export function sessionRecurrence({ firstDate, lastDate, interval, startTime, timeZone, includeDates, weekdays = null }) {
|
||||
const include = new Set(includeDates || []);
|
||||
const step = Math.max(1, Number(interval) || 2);
|
||||
const allowed = Array.isArray(weekdays) && weekdays.some((day) => Number(day) >= 1 && Number(day) <= 6)
|
||||
? new Set(weekdays.map(Number).filter((day) => day >= 1 && day <= 6))
|
||||
: null;
|
||||
const step = allowed ? 1 : Math.max(1, Number(interval) || 2);
|
||||
const time = `${String(startTime || '09:00').replace(':', '')}00`.slice(0, 6);
|
||||
const stamp = (ymd) => `${String(ymd).replace(/-/g, '')}T${time}`;
|
||||
let cursor = firstDate;
|
||||
let count = 0;
|
||||
const exdates = [];
|
||||
while (cursor <= lastDate && count < 80) {
|
||||
count += 1;
|
||||
if (include.size && !include.has(cursor)) exdates.push(cursor);
|
||||
let guard = 0;
|
||||
while (cursor <= lastDate && count < 120 && guard < 500) {
|
||||
guard += 1;
|
||||
const sunday = weekdayOf(cursor) === 0;
|
||||
const weekdayBlocked = allowed ? !allowed.has(weekdayOf(cursor)) : false;
|
||||
if (sunday && !allowed) {
|
||||
count += 1;
|
||||
exdates.push(cursor);
|
||||
} else if (!weekdayBlocked && !sunday) {
|
||||
count += 1;
|
||||
if (include.size && !include.has(cursor)) exdates.push(cursor);
|
||||
}
|
||||
cursor = addDays(cursor, step);
|
||||
}
|
||||
const lines = [`RRULE:FREQ=DAILY;INTERVAL=${step};COUNT=${Math.max(count, 1)}`];
|
||||
const byDay = allowed
|
||||
? [...allowed].sort((a, b) => a - b).map((day) => RRULE_DAYS[day]).filter(Boolean).join(',')
|
||||
: '';
|
||||
const lines = [byDay
|
||||
? `RRULE:FREQ=WEEKLY;BYDAY=${byDay};COUNT=${Math.max(count, 1)}`
|
||||
: `RRULE:FREQ=DAILY;INTERVAL=${step};COUNT=${Math.max(count, 1)}`];
|
||||
if (exdates.length) {
|
||||
lines.push(`EXDATE;TZID=${timeZone || 'Asia/Kolkata'}:${exdates.map(stamp).join(',')}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import Boom from '@hapi/boom';
|
||||
import crypto from 'node:crypto';
|
||||
import { Settings } from '../models/Settings.js';
|
||||
|
||||
const TIME = /^\d{2}:\d{2}$/;
|
||||
|
|
@ -29,7 +30,7 @@ export const DEFAULT_SETTINGS = {
|
|||
defaultDurationMinutes: 60,
|
||||
skipHolidays: true,
|
||||
skipPrtLeave: true,
|
||||
languages: ['Tamil', 'Telagu', 'Hindi', 'Malayalam', 'Bangali', 'Kannda', 'Marathi'],
|
||||
languages: ['Tamil', 'Telugu', 'Hindi', 'Malayalam', 'Bengali', 'Kannada', 'Marathi'],
|
||||
paymentStatuses: ['Unpaid', 'Paid', 'Partial'],
|
||||
genders: ['Male', 'Female', 'Other'],
|
||||
diseaseTypes: ['ILD', 'OAD'],
|
||||
|
|
@ -38,6 +39,7 @@ export const DEFAULT_SETTINGS = {
|
|||
matchingPriority: ['language', 'slot', 'weekday', 'disease'],
|
||||
matchingCron: '0 7 * * 6',
|
||||
piiRetentionDays: 365,
|
||||
cohortSchedules: [],
|
||||
whatsappAdminIds: [],
|
||||
whatsappReminderRules: [],
|
||||
whatsappTaskGapMinMs: 15000,
|
||||
|
|
@ -86,19 +88,45 @@ export function normalizeSlots(slots, durationMinutes = 60) {
|
|||
return next;
|
||||
}
|
||||
|
||||
// Keeps meetings per cohort between 1 and 60.
|
||||
function meetingCount(value, fallback) {
|
||||
const n = Number(value);
|
||||
const base = Number.isFinite(n) && n >= 1 ? n : Number(fallback) || 30;
|
||||
return Math.min(Math.max(Math.round(base), 1), 60);
|
||||
}
|
||||
|
||||
// Each extra schedule needs a name, a day other than Sunday, and its own meeting count.
|
||||
function normalizeCohortSchedules(rows, fallbackCount) {
|
||||
const next = [];
|
||||
const seen = new Set();
|
||||
for (const row of rows || []) {
|
||||
const name = String(row?.name || '').trim();
|
||||
const weekdays = [...new Set((row?.weekdays || []).map(Number))]
|
||||
.filter((day) => day >= 1 && day <= 6)
|
||||
.sort((a, b) => a - b);
|
||||
if (!name) throw Boom.badRequest('Each extra schedule needs a name.');
|
||||
if (!weekdays.length) throw Boom.badRequest(`${name} needs a day other than Sunday. Sunday is always skipped.`);
|
||||
let id = String(row?.id || '').trim();
|
||||
if (!id || seen.has(id)) id = crypto.randomUUID();
|
||||
seen.add(id);
|
||||
next.push({ id, name, weekdays, upcomingSessionCount: meetingCount(row?.upcomingSessionCount, fallbackCount) });
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function normalizeSettingsPayload(payload = {}) {
|
||||
const slotDurationMinutes = Number(payload.slotDurationMinutes ?? DEFAULT_SETTINGS.slotDurationMinutes);
|
||||
if (slotDurationMinutes < 15 || slotDurationMinutes > 180) throw Boom.badRequest('Slot duration must be between 15 and 180 minutes.');
|
||||
const repeatIntervalDays = Number(payload.repeatIntervalDays ?? DEFAULT_SETTINGS.repeatIntervalDays);
|
||||
if (repeatIntervalDays < 1 || repeatIntervalDays > 30) throw Boom.badRequest('Repeat interval must be between 1 and 30 days.');
|
||||
const upcomingSessionCount = Number(payload.upcomingSessionCount ?? DEFAULT_SETTINGS.upcomingSessionCount);
|
||||
if (upcomingSessionCount < 1 || upcomingSessionCount > 40) throw Boom.badRequest('Upcoming session count must be between 1 and 40.');
|
||||
if (upcomingSessionCount < 1 || upcomingSessionCount > 60) throw Boom.badRequest('Upcoming session count must be between 1 and 60.');
|
||||
const defaultDurationMinutes = Number(payload.defaultDurationMinutes ?? DEFAULT_SETTINGS.defaultDurationMinutes);
|
||||
if (defaultDurationMinutes < 15 || defaultDurationMinutes > 180) throw Boom.badRequest('Meeting duration must be between 15 and 180 minutes.');
|
||||
const cohortStartWeekdays = [...new Set((payload.cohortStartWeekdays ?? DEFAULT_SETTINGS.cohortStartWeekdays).map(Number))]
|
||||
.filter((day) => day >= 0 && day <= 6)
|
||||
.filter((day) => day >= 1 && day <= 6)
|
||||
.sort((a, b) => a - b);
|
||||
if (!cohortStartWeekdays.length) throw Boom.badRequest('Select at least one weekday for cohort start dates.');
|
||||
if (!cohortStartWeekdays.length) throw Boom.badRequest('Select at least one weekday other than Sunday for cohort start dates.');
|
||||
return {
|
||||
slots: normalizeSlots(payload.slots ?? DEFAULT_SETTINGS.slots, slotDurationMinutes),
|
||||
slotDurationMinutes,
|
||||
|
|
@ -117,6 +145,7 @@ export function normalizeSettingsPayload(payload = {}) {
|
|||
matchingPriority: cleanStrings(payload.matchingPriority ?? DEFAULT_SETTINGS.matchingPriority, 'matching priority'),
|
||||
matchingCron: String(payload.matchingCron || DEFAULT_SETTINGS.matchingCron).trim() || DEFAULT_SETTINGS.matchingCron,
|
||||
piiRetentionDays: Math.min(Math.max(Number(payload.piiRetentionDays ?? DEFAULT_SETTINGS.piiRetentionDays) || 365, 1), 3650),
|
||||
cohortSchedules: normalizeCohortSchedules(payload.cohortSchedules ?? DEFAULT_SETTINGS.cohortSchedules, upcomingSessionCount),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -175,6 +204,12 @@ export function toSettingsDto(settings) {
|
|||
matchingPriority: settings.matchingPriority?.length ? settings.matchingPriority : DEFAULT_SETTINGS.matchingPriority,
|
||||
matchingCron: settings.matchingCron || DEFAULT_SETTINGS.matchingCron,
|
||||
piiRetentionDays: settings.piiRetentionDays || DEFAULT_SETTINGS.piiRetentionDays,
|
||||
cohortSchedules: (settings.cohortSchedules || []).map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
weekdays: row.weekdays || [],
|
||||
upcomingSessionCount: meetingCount(row.upcomingSessionCount, settings.upcomingSessionCount || DEFAULT_SETTINGS.upcomingSessionCount),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { readOnboardedPii } from '../services/cryptoPii.js';
|
||||
import { toYmd, weekdayOf } from '../services/schedule.js';
|
||||
|
||||
export function userLanguages(user) {
|
||||
const fromArray = (user?.languages || []).map((item) => String(item || '').trim()).filter(Boolean);
|
||||
|
|
@ -208,7 +209,7 @@ export function toOnboardedPatientDto(item) {
|
|||
? { id: String(item.assignedEducator) }
|
||||
: null,
|
||||
cohort: item.cohort && typeof item.cohort === 'object'
|
||||
? { id: String(item.cohort._id), name: item.cohort.name, startDate: item.cohort.startDate, slot: item.cohort.slot, language: item.cohort.language || '' }
|
||||
? { id: String(item.cohort._id), name: item.cohort.name, startDate: toYmd(item.cohort.startDate), slot: item.cohort.slot, language: item.cohort.language || '' }
|
||||
: item.cohort
|
||||
? { id: String(item.cohort) }
|
||||
: null,
|
||||
|
|
@ -242,23 +243,17 @@ function userBrief(user) {
|
|||
return { id: String(user) };
|
||||
}
|
||||
|
||||
function weekdayFromDate(value) {
|
||||
if (!value) return null;
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
return date.getDay();
|
||||
}
|
||||
|
||||
export function toCohortDto(cohort) {
|
||||
if (!cohort) return null;
|
||||
const patients = Array.isArray(cohort.patients) ? cohort.patients : [];
|
||||
return {
|
||||
id: String(cohort._id),
|
||||
name: cohort.name,
|
||||
startDate: cohort.startDate,
|
||||
weekday: weekdayFromDate(cohort.startDate),
|
||||
startDate: toYmd(cohort.startDate),
|
||||
weekday: cohort.startDate ? weekdayOf(toYmd(cohort.startDate)) : null,
|
||||
slot: cohort.slot || '',
|
||||
language: cohort.language || '',
|
||||
scheduleId: cohort.scheduleId || '',
|
||||
diseaseType: cohort.diseaseType || '',
|
||||
status: cohort.status || 'active',
|
||||
needsReassignment: Boolean(cohort.needsReassignment),
|
||||
|
|
@ -355,6 +350,8 @@ export function toCohortSessionDto(session) {
|
|||
eq5d3l: record.assessment?.eq5d3l || '',
|
||||
sgrq: record.assessment?.sgrq || '',
|
||||
sitToStand: record.assessment?.sitToStand || '',
|
||||
rpeRest: record.assessment?.rpeRest || '',
|
||||
rpeActivity: record.assessment?.rpeActivity || '',
|
||||
o2Regular: record.assessment?.o2Regular || '',
|
||||
o2WithExercise: record.assessment?.o2WithExercise || '',
|
||||
},
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 72 KiB |
|
|
@ -14,6 +14,8 @@ function emptyAssessment() {
|
|||
eq5d3l: '',
|
||||
sgrq: '',
|
||||
sitToStand: '',
|
||||
rpeRest: '',
|
||||
rpeActivity: '',
|
||||
o2Regular: '',
|
||||
o2WithExercise: '',
|
||||
};
|
||||
|
|
@ -26,6 +28,8 @@ const ASSESSMENT_FIELDS = [
|
|||
['eq5d3l', 'EQ-5D'],
|
||||
['sgrq', 'SGRQ'],
|
||||
['sitToStand', 'Sit-to-stand'],
|
||||
['rpeRest', 'RPE at rest'],
|
||||
['rpeActivity', 'RPE with activity'],
|
||||
['o2Regular', 'Oxygen at rest'],
|
||||
['o2WithExercise', 'Oxygen with exercise'],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ const empty = {
|
|||
virtualPrt: '',
|
||||
language: '',
|
||||
diseaseType: '',
|
||||
scheduleId: '',
|
||||
patients: [],
|
||||
};
|
||||
|
||||
|
|
@ -23,6 +24,22 @@ function prtLanguages(prt) {
|
|||
return prt.language ? [prt.language] : [];
|
||||
}
|
||||
|
||||
// Keeps the date if it fits the schedule, otherwise moves it to the next allowed day.
|
||||
function nextAllowedDate(startDate, days) {
|
||||
const allowed = new Set((days || []).map(Number).filter((day) => day > 0 && day <= 6));
|
||||
if (!startDate) return '';
|
||||
const cursor = new Date(`${String(startDate).slice(0, 10)}T00:00:00`);
|
||||
if (Number.isNaN(cursor.getTime()) || !allowed.size) return '';
|
||||
for (let i = 0; i < 14; i += 1) {
|
||||
if (allowed.has(cursor.getDay())) {
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
return `${cursor.getFullYear()}-${pad(cursor.getMonth() + 1)}-${pad(cursor.getDate())}`;
|
||||
}
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export default function CohortForm() {
|
||||
const { id } = useParams();
|
||||
const isEdit = Boolean(id);
|
||||
|
|
@ -38,7 +55,10 @@ export default function CohortForm() {
|
|||
const [saving, setSaving] = useState(false);
|
||||
const { settings } = useSettings();
|
||||
const startWeekdays = settings?.cohortStartWeekdays || [1, 2];
|
||||
const sessionCount = settings?.upcomingSessionCount || 30;
|
||||
const schedules = settings?.cohortSchedules || [];
|
||||
const chosenSchedule = schedules.find((item) => item.id === form.scheduleId);
|
||||
const allowedDays = (chosenSchedule?.weekdays?.length ? chosenSchedule.weekdays.map(Number) : startWeekdays).filter((day) => day !== 0);
|
||||
const sessionCount = chosenSchedule?.upcomingSessionCount || settings?.upcomingSessionCount || 30;
|
||||
|
||||
useEffect(() => {
|
||||
api('/api/users/virtual-prts').then((d) => setPrts(d.items || [])).catch(() => {});
|
||||
|
|
@ -55,8 +75,10 @@ export default function CohortForm() {
|
|||
}, [patientSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEdit) return;
|
||||
if (!isEdit) return undefined;
|
||||
let active = true;
|
||||
api(`/api/cohorts/${id}`).then((c) => {
|
||||
if (!active) return;
|
||||
setForm({
|
||||
name: c.name || '',
|
||||
startDate: c.startDate ? String(c.startDate).slice(0, 10) : '',
|
||||
|
|
@ -64,12 +86,26 @@ export default function CohortForm() {
|
|||
virtualPrt: c.virtualPrt?.id || '',
|
||||
language: c.language || '',
|
||||
diseaseType: c.diseaseType || '',
|
||||
scheduleId: c.scheduleId || '',
|
||||
patients: (c.patients || []).map((p) => p.id),
|
||||
});
|
||||
setSelectedDetails((c.patients || []).map((p) => ({ id: p.id, name: p.name, mobile: p.mobile, whatsappOptOut: p.whatsappOptOut })));
|
||||
setSessionSummary({ completed: c.sessionsCompleted || 0, planned: c.sessionsPlanned || 0 });
|
||||
}).catch((err) => setError(err.message));
|
||||
api(`/api/cohorts/${id}/sessions?count=${sessionCount}`).then((d) => setSessions(d.items || [])).catch(() => {});
|
||||
}).catch((err) => {
|
||||
if (active) setError(err.message);
|
||||
});
|
||||
return () => { active = false; };
|
||||
}, [id, isEdit]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEdit) return undefined;
|
||||
let active = true;
|
||||
api(`/api/cohorts/${id}/sessions?count=${sessionCount}`).then((d) => {
|
||||
if (active) setSessions(d.items || []);
|
||||
}).catch(() => {
|
||||
if (active) setSessions([]);
|
||||
});
|
||||
return () => { active = false; };
|
||||
}, [id, isEdit, sessionCount]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -141,12 +177,12 @@ export default function CohortForm() {
|
|||
<CardBody>
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
<p className="text-muted">
|
||||
Start date must be {formatWeekdays(startWeekdays) || 'a configured weekday'}
|
||||
{chosenSchedule
|
||||
? `This schedule meets on ${formatWeekdays(allowedDays)} and plans ${chosenSchedule.upcomingSessionCount || sessionCount} meetings.`
|
||||
: `Start date must be ${formatWeekdays(startWeekdays) || 'a configured weekday'}. Meetings then repeat ${formatInterval(settings?.repeatIntervalDays)}.`}
|
||||
{settings?.skipHolidays !== false || settings?.skipPrtLeave !== false
|
||||
? `, and not fall on ${[settings?.skipHolidays !== false ? 'a holiday' : null, settings?.skipPrtLeave !== false ? 'Virtual PRT leave' : null].filter(Boolean).join(' or ')}`
|
||||
: ''}.
|
||||
Meetings then repeat {formatInterval(settings?.repeatIntervalDays)}
|
||||
{settings?.skipHolidays !== false || settings?.skipPrtLeave !== false ? ', skipping blocked days' : ''}.
|
||||
? ` Skipped ${[settings?.skipHolidays !== false ? 'holidays' : null, settings?.skipPrtLeave !== false ? 'Virtual PRT leave' : null].filter(Boolean).join(' and ')} do not count as a meeting.`
|
||||
: ''}
|
||||
</p>
|
||||
<form onSubmit={save}>
|
||||
<Row className="g-3">
|
||||
|
|
@ -155,14 +191,36 @@ export default function CohortForm() {
|
|||
<FormControl value={form.name} readOnly plaintext={false} placeholder="Created from slot, PRT language, and start day" />
|
||||
<div className="form-text">Auto-created as timeslot - language weekday.</div>
|
||||
</Col>
|
||||
<Col md={3}>
|
||||
<FormLabel>Schedule</FormLabel>
|
||||
<select
|
||||
className="form-select"
|
||||
value={form.scheduleId}
|
||||
onChange={(e) => {
|
||||
const scheduleId = e.target.value;
|
||||
const next = schedules.find((item) => item.id === scheduleId);
|
||||
const days = (next?.weekdays?.length ? next.weekdays.map(Number) : startWeekdays).filter((day) => day !== 0);
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
scheduleId,
|
||||
startDate: nextAllowedDate(current.startDate, days),
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<option value="">Normal (current)</option>
|
||||
{schedules.map((item) => (
|
||||
<option key={item.id} value={item.id}>{item.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</Col>
|
||||
<Col md={3}>
|
||||
<FormLabel>Start date</FormLabel>
|
||||
<DateField
|
||||
value={form.startDate}
|
||||
onChange={(startDate) => setForm((f) => ({ ...f, startDate }))}
|
||||
required
|
||||
filterDate={(date) => startWeekdays.includes(date.getDay())}
|
||||
placeholder={formatWeekdays(startWeekdays) || 'Select date'}
|
||||
filterDate={(date) => date.getDay() !== 0 && allowedDays.includes(date.getDay())}
|
||||
placeholder={formatWeekdays(allowedDays) || 'Select date'}
|
||||
/>
|
||||
</Col>
|
||||
<Col md={3}>
|
||||
|
|
@ -236,7 +294,9 @@ export default function CohortForm() {
|
|||
<div className="mt-3">
|
||||
<h6>Upcoming sessions</h6>
|
||||
<p className="text-muted mb-2">
|
||||
Repeats {formatInterval(settings?.repeatIntervalDays)} from the start date
|
||||
{chosenSchedule
|
||||
? `Meets on ${formatWeekdays(allowedDays)} for ${chosenSchedule.upcomingSessionCount || sessionCount} meetings`
|
||||
: `Repeats ${formatInterval(settings?.repeatIntervalDays)} from the start date`}
|
||||
{settings?.skipHolidays !== false || settings?.skipPrtLeave !== false ? ', skipping blocked days' : ''}.
|
||||
</p>
|
||||
<div className="d-flex flex-wrap gap-2">
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
|
|||
import { Button, Card, CardBody, Col, Container, Row, Table } from 'react-bootstrap';
|
||||
import { Link } from 'react-router';
|
||||
import PageBreadcrumb from '@/components/PageBreadcrumb';
|
||||
import { WEEKDAY_LABELS } from '@/hooks/useSettings';
|
||||
import { WEEKDAY_LABELS, useSettings } from '@/hooks/useSettings';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
function weekdayLabel(weekday) {
|
||||
|
|
@ -34,6 +34,8 @@ export default function MatchingPage() {
|
|||
const [info, setInfo] = useState('');
|
||||
const [notes, setNotes] = useState([]);
|
||||
const [busy, setBusy] = useState('');
|
||||
const { settings } = useSettings();
|
||||
const schedules = settings?.cohortSchedules || [];
|
||||
|
||||
const loadEmptySlots = async (items) => {
|
||||
const ids = [...new Set((items || []).map((cohort) => cohort.virtualPrt?.id).filter(Boolean))];
|
||||
|
|
@ -158,6 +160,24 @@ export default function MatchingPage() {
|
|||
</div>
|
||||
<Button size="sm" disabled={busy} onClick={() => act(`confirm-${cohort.id}`, () => api(`/api/matching/proposals/${cohort.id}/confirm`, { method: 'POST' }), `${cohort.name} confirmed.`)}>Confirm</Button>
|
||||
</div>
|
||||
<select
|
||||
className="form-select form-select-sm mt-2"
|
||||
style={{ maxWidth: 280 }}
|
||||
value={cohort.scheduleId || ''}
|
||||
disabled={Boolean(busy)}
|
||||
onChange={(e) => act(
|
||||
`schedule-${cohort.id}`,
|
||||
() => api(`/api/matching/proposals/${cohort.id}/schedule`, {
|
||||
method: 'PATCH',
|
||||
body: { scheduleId: e.target.value },
|
||||
}),
|
||||
)}
|
||||
>
|
||||
<option value="">Normal (current)</option>
|
||||
{schedules.map((item) => (
|
||||
<option key={item.id} value={item.id}>{item.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="small mt-2">
|
||||
{(cohort.patients || []).map((patient) => (
|
||||
<div key={patient.id} className="d-flex justify-content-between align-items-center py-1">
|
||||
|
|
|
|||
|
|
@ -217,8 +217,29 @@ export default function OnboardingPage() {
|
|||
<div className="d-flex flex-wrap gap-2 align-items-center w-100">
|
||||
<form className="d-flex flex-wrap gap-2 align-items-center flex-grow-1" onSubmit={(e) => { e.preventDefault(); setPage(0); setQuery(search); }}>
|
||||
<div className="app-search flex-shrink-0" style={{ width: 240 }}>
|
||||
<input className="form-control" placeholder="Search name or mobile..." value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
<input className="form-control" placeholder="Search name or mobile..." value={search} style={search ? { paddingRight: 32 } : undefined} onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setSearch(value);
|
||||
if (!value.trim()) {
|
||||
setPage(0);
|
||||
setQuery('');
|
||||
}
|
||||
}} />
|
||||
<LuSearch className="app-search-icon text-muted" />
|
||||
{search ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-link position-absolute top-50 translate-middle-y end-0 text-muted p-0 pe-2"
|
||||
aria-label="Clear search"
|
||||
onClick={() => {
|
||||
setSearch('');
|
||||
setPage(0);
|
||||
setQuery('');
|
||||
}}
|
||||
>
|
||||
<TbX />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<Button type="submit" variant="light">Search</Button>
|
||||
<select className="form-select" style={{ width: 150 }} value={list} onChange={(e) => { setPage(0); setList(e.target.value); }}>
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ function emptyForm() {
|
|||
matchingPriority: ['language', 'slot', 'weekday', 'disease'],
|
||||
matchingCron: '0 7 * * 6',
|
||||
piiRetentionDays: 365,
|
||||
cohortSchedules: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -95,6 +96,12 @@ export default function SettingsPage() {
|
|||
matchingPriority: data.matchingPriority || ['language', 'slot', 'weekday', 'disease'],
|
||||
matchingCron: data.matchingCron || '0 7 * * 6',
|
||||
piiRetentionDays: data.piiRetentionDays || 365,
|
||||
cohortSchedules: (data.cohortSchedules || []).map((row) => ({
|
||||
id: row.id || '',
|
||||
name: row.name || '',
|
||||
weekdays: row.weekdays || [],
|
||||
upcomingSessionCount: row.upcomingSessionCount || data.upcomingSessionCount || 30,
|
||||
})),
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -157,6 +164,12 @@ export default function SettingsPage() {
|
|||
genders: form.genders.map((item) => item.trim()).filter(Boolean),
|
||||
diseaseTypes: form.diseaseTypes.map((item) => item.trim()).filter(Boolean),
|
||||
matchingPriority: form.matchingPriority.map((item) => item.trim()).filter(Boolean),
|
||||
cohortSchedules: (form.cohortSchedules || []).map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
weekdays: row.weekdays,
|
||||
upcomingSessionCount: Number(row.upcomingSessionCount) || Number(form.upcomingSessionCount) || 30,
|
||||
})),
|
||||
},
|
||||
});
|
||||
await load();
|
||||
|
|
@ -187,8 +200,9 @@ export default function SettingsPage() {
|
|||
key={label}
|
||||
type="checkbox"
|
||||
id={`weekday-${day}`}
|
||||
label={label}
|
||||
checked={form.cohortStartWeekdays.includes(day)}
|
||||
label={day === 0 ? `${label} (always skipped)` : label}
|
||||
checked={day !== 0 && form.cohortStartWeekdays.includes(day)}
|
||||
disabled={day === 0}
|
||||
onChange={() => toggleWeekday(day)}
|
||||
/>
|
||||
))}
|
||||
|
|
@ -211,7 +225,7 @@ export default function SettingsPage() {
|
|||
<FormControl
|
||||
type="number"
|
||||
min={1}
|
||||
max={40}
|
||||
max={60}
|
||||
value={form.upcomingSessionCount}
|
||||
onChange={(e) => setField('upcomingSessionCount', e.target.value)}
|
||||
required
|
||||
|
|
@ -230,6 +244,87 @@ export default function SettingsPage() {
|
|||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<div className="mt-4">
|
||||
<FormLabel>Extra schedules</FormLabel>
|
||||
<p className="text-muted small mb-2">
|
||||
Normal stays the Repeat every rule and the Meetings per cohort number above. Each extra schedule has its own Meetings per cohort. A skipped holiday or leave does not count as a meeting.
|
||||
</p>
|
||||
{(form.cohortSchedules || []).map((row, index) => (
|
||||
<div key={row.id || index} className="border rounded p-3 mb-2">
|
||||
<div className="d-flex gap-2 mb-2">
|
||||
<FormControl
|
||||
value={row.name}
|
||||
placeholder="Schedule name"
|
||||
onChange={(e) => setForm((current) => ({
|
||||
...current,
|
||||
cohortSchedules: current.cohortSchedules.map((item, i) => (i === index ? { ...item, name: e.target.value } : item)),
|
||||
}))}
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline-danger"
|
||||
size="sm"
|
||||
className="btn-icon"
|
||||
onClick={() => setForm((current) => ({
|
||||
...current,
|
||||
cohortSchedules: current.cohortSchedules.filter((_, i) => i !== index),
|
||||
}))}
|
||||
>
|
||||
<TbTrash className="fs-lg" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mb-2" style={{ maxWidth: 220 }}>
|
||||
<FormLabel>Meetings per cohort</FormLabel>
|
||||
<FormControl
|
||||
type="number"
|
||||
min={1}
|
||||
max={60}
|
||||
value={row.upcomingSessionCount}
|
||||
onChange={(e) => setForm((current) => ({
|
||||
...current,
|
||||
cohortSchedules: current.cohortSchedules.map((item, i) => (i === index ? { ...item, upcomingSessionCount: e.target.value } : item)),
|
||||
}))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="d-flex flex-wrap gap-3">
|
||||
{WEEKDAY_LABELS.map((label, day) => (
|
||||
<FormCheck
|
||||
key={label}
|
||||
type="checkbox"
|
||||
id={`schedule-${index}-${day}`}
|
||||
label={day === 0 ? `${label} (always skipped)` : label}
|
||||
checked={day !== 0 && (row.weekdays || []).includes(day)}
|
||||
disabled={day === 0}
|
||||
onChange={() => setForm((current) => ({
|
||||
...current,
|
||||
cohortSchedules: current.cohortSchedules.map((item, i) => {
|
||||
if (i !== index) return item;
|
||||
const has = (item.weekdays || []).includes(day);
|
||||
const weekdays = has
|
||||
? item.weekdays.filter((value) => value !== day)
|
||||
: [...(item.weekdays || []), day].sort((a, b) => a - b);
|
||||
return { ...item, weekdays };
|
||||
}),
|
||||
}))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="light"
|
||||
size="sm"
|
||||
onClick={() => setForm((current) => ({
|
||||
...current,
|
||||
cohortSchedules: [...(current.cohortSchedules || []), { id: '', name: '', weekdays: [1], upcomingSessionCount: Number(current.upcomingSessionCount) || 30 }],
|
||||
}))}
|
||||
>
|
||||
<TbPlus className="me-1" /> Add schedule
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<FormCheck
|
||||
type="switch"
|
||||
|
|
|
|||
Loading…
Reference in New Issue