added New Prt option and fixed mobile number issue
This commit is contained in:
parent
de373aaba3
commit
41a010748c
|
|
@ -5,6 +5,7 @@ export const ACL_MODULES = [
|
|||
{ key: 'sessions', label: 'Sessions' },
|
||||
{ key: 'onboarding', label: 'Patients' },
|
||||
{ key: 'cohorts', label: 'Cohorts' },
|
||||
{ key: 'prt_cohorts', label: 'Add PRT cohorts' },
|
||||
{ key: 'holidays', label: 'Holidays' },
|
||||
{ key: 'leaves', label: 'Leave' },
|
||||
{ key: 'matching', label: 'Matching' },
|
||||
|
|
@ -29,6 +30,7 @@ export const SYSTEM_ROLE_PERMISSIONS = {
|
|||
perm('sessions', ['view', 'add', 'edit', 'delete']),
|
||||
perm('onboarding', ['view', 'add', 'edit', 'delete']),
|
||||
perm('cohorts', ['view', 'add', 'edit', 'delete']),
|
||||
perm('prt_cohorts', ['view', 'add', 'edit', 'delete']),
|
||||
perm('holidays', ['view', 'add', 'edit', 'delete']),
|
||||
perm('leaves', ['view', 'add', 'edit', 'delete']),
|
||||
perm('matching', ['view', 'add', 'edit', 'delete']),
|
||||
|
|
@ -43,6 +45,7 @@ export const SYSTEM_ROLE_PERMISSIONS = {
|
|||
perm('sessions', ['view', 'add', 'edit', 'delete']),
|
||||
perm('onboarding', ['view', 'add', 'edit', 'delete']),
|
||||
perm('cohorts', ['view', 'add', 'edit', 'delete']),
|
||||
perm('prt_cohorts', ['view', 'add', 'edit', 'delete']),
|
||||
perm('holidays', ['view', 'add', 'edit', 'delete']),
|
||||
perm('leaves', ['view', 'add', 'edit', 'delete']),
|
||||
perm('matching', ['view', 'add', 'edit', 'delete']),
|
||||
|
|
@ -53,6 +56,7 @@ export const SYSTEM_ROLE_PERMISSIONS = {
|
|||
educator: [
|
||||
perm('onboarding', ['view', 'add', 'edit', 'delete'], 'assigned'),
|
||||
perm('cohorts', ['view'], 'assigned'),
|
||||
perm('prt_cohorts', ['view', 'add', 'edit', 'delete'], 'assigned'),
|
||||
perm('holidays', ['view']),
|
||||
perm('leaves', ['view', 'add', 'edit'], 'assigned'),
|
||||
perm('program_sessions', ['view', 'edit'], 'assigned'),
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ const cohortSchema = new mongoose.Schema(
|
|||
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
|
||||
createdAs: { type: String, enum: ['cohorts', 'manual_cohorts'], default: 'cohorts' },
|
||||
sixDayWeek: { type: Boolean, default: false }, // manual cohort: yes allows any start except Sunday
|
||||
diseaseType: { type: String, default: '', trim: true },
|
||||
status: {
|
||||
type: String,
|
||||
|
|
|
|||
|
|
@ -73,14 +73,17 @@ export function authResponse(user, token) {
|
|||
}
|
||||
|
||||
export function requirePermission(moduleKey, action) {
|
||||
return requireAnyPermission([moduleKey], action);
|
||||
}
|
||||
|
||||
// Allows the action when any one of the modules grants it.
|
||||
export function requireAnyPermission(moduleKeys, action) {
|
||||
return (request, h) => {
|
||||
const creds = request.auth?.credentials;
|
||||
if (!creds?.user) throw Boom.unauthorized();
|
||||
if (creds.isSuperAdmin) return h.continue;
|
||||
const actions = creds.modules?.[moduleKey] || [];
|
||||
if (!Array.isArray(actions) || !actions.includes(action)) {
|
||||
throw Boom.forbidden('Insufficient permissions.');
|
||||
}
|
||||
const allowed = moduleKeys.some((moduleKey) => (creds.modules?.[moduleKey] || []).includes(action));
|
||||
if (!allowed) throw Boom.forbidden('Insufficient permissions.');
|
||||
return h.continue;
|
||||
};
|
||||
}
|
||||
|
|
@ -116,10 +119,31 @@ export async function onboardingScopeFilter(request) {
|
|||
};
|
||||
}
|
||||
|
||||
// Cohorts this user may see: the type their permission allows, and only their own when that permission is assigned.
|
||||
export function cohortScopeFilter(request) {
|
||||
const creds = request.auth.credentials;
|
||||
if (creds.isSuperAdmin || creds.scopes?.cohorts !== 'assigned') return {};
|
||||
return { virtualPrt: creds.user._id };
|
||||
if (creds.isSuperAdmin) return {};
|
||||
const canView = (key) => (creds.modules?.[key] || []).includes('view');
|
||||
const mine = { virtualPrt: creds.user._id };
|
||||
const normalType = {
|
||||
$or: [
|
||||
{ createdAs: 'cohorts' },
|
||||
{ createdAs: { $exists: false } },
|
||||
{ createdAs: null },
|
||||
{ createdAs: '' },
|
||||
],
|
||||
};
|
||||
const clauses = [];
|
||||
if (canView('cohorts')) {
|
||||
clauses.push(creds.scopes?.cohorts === 'assigned' ? { $and: [normalType, mine] } : normalType);
|
||||
}
|
||||
if (canView('prt_cohorts')) {
|
||||
const manualType = { createdAs: 'manual_cohorts' };
|
||||
clauses.push(creds.scopes?.prt_cohorts === 'assigned' ? { ...manualType, virtualPrt: creds.user._id } : manualType);
|
||||
}
|
||||
if (!clauses.length) return { _id: null };
|
||||
if (clauses.length === 1) return clauses[0];
|
||||
return { $or: clauses };
|
||||
}
|
||||
|
||||
export function leaveScopeFilter(request) {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { env } from '../config/env.js';
|
|||
import { User } from '../models/User.js';
|
||||
import { sendPasswordResetEmail } from '../services/mail.js';
|
||||
import { writeAudit, clientIp } from '../services/audit.js';
|
||||
import { isMobileNumber } from '../services/cryptoPii.js';
|
||||
import { hitRateLimit } from '../services/rateLimit.js';
|
||||
import {
|
||||
applySessionCookies,
|
||||
|
|
@ -117,6 +118,9 @@ export function authRoutes(server) {
|
|||
if (!fresh || !fresh.isActive) throw Boom.unauthorized();
|
||||
fresh.firstName = request.payload.firstName;
|
||||
fresh.lastName = request.payload.lastName;
|
||||
if (request.payload.phone != null && String(request.payload.phone).trim() && !isMobileNumber(request.payload.phone)) {
|
||||
throw Boom.badRequest('Mobile must be 10 digits.');
|
||||
}
|
||||
if (request.payload.phone != null) fresh.phone = request.payload.phone;
|
||||
if (request.payload.specializations) fresh.specializations = request.payload.specializations;
|
||||
if (request.payload.availableSlots) fresh.availableSlots = request.payload.availableSlots;
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import Joi from 'joi';
|
||||
import Boom from '@hapi/boom';
|
||||
import { requirePermission, cohortScopeFilter, credentials } from '../plugins/auth.js';
|
||||
import { requireAnyPermission, cohortScopeFilter, credentials } from '../plugins/auth.js';
|
||||
import { Cohort } from '../models/Cohort.js';
|
||||
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, scheduleSessionCount, scheduleWeekdays, toYmd, upcomingSessionDates, ymdToDate } from '../services/schedule.js';
|
||||
import { releaseCohortPatients, syncCohortPatients } from '../services/cohorts.js';
|
||||
import { releaseCohortPatients, syncCohortPatients, applyCohortToPatients, createManualCohortPatients } from '../services/cohorts.js';
|
||||
import { activateManualCohort } from '../services/matching.js';
|
||||
import { ensureSessionRecords, persistCohortSessions, rescheduleOpenSessions } from '../services/programSessions.js';
|
||||
import { CohortSession } from '../models/CohortSession.js';
|
||||
|
|
@ -26,8 +26,23 @@ const cohortPayload = Joi.object({
|
|||
patients: Joi.array().items(objectId).default([]),
|
||||
durationMinutes: Joi.number().min(15).max(180).optional(),
|
||||
scheduleId: Joi.string().trim().allow('', null),
|
||||
createdAs: Joi.string().valid('cohorts', 'manual_cohorts'),
|
||||
sixDayWeek: Joi.boolean(),
|
||||
newPatients: Joi.array().items(Joi.object({
|
||||
name: Joi.string().trim().required(),
|
||||
mobile: Joi.string().trim().pattern(/^\d{10}$/).required(),
|
||||
age: Joi.alternatives().try(Joi.number(), Joi.string().allow('')).optional(),
|
||||
gender: Joi.string().trim().allow('').optional(),
|
||||
whatsappOptOut: Joi.boolean().optional(),
|
||||
})).default([]),
|
||||
});
|
||||
|
||||
// Manual cohorts start on Monday–Saturday, or only Monday and Tuesday.
|
||||
function startWeekdaysFor(payload, scheduleDays) {
|
||||
if (payload.createdAs !== 'manual_cohorts') return scheduleDays;
|
||||
return payload.sixDayWeek ? [1, 2, 3, 4, 5, 6] : [1, 2];
|
||||
}
|
||||
|
||||
// Saved extra-schedule id to its weekdays. Blank means Normal.
|
||||
async function chosenWeekdays(scheduleId) {
|
||||
const id = String(scheduleId || '').trim();
|
||||
|
|
@ -88,7 +103,7 @@ export function cohortRoutes(server) {
|
|||
server.route({
|
||||
method: 'GET',
|
||||
path: '/api/cohorts',
|
||||
options: { pre: [{ method: requirePermission('cohorts', 'view') }] },
|
||||
options: { pre: [{ method: requireAnyPermission(['cohorts', 'prt_cohorts'], 'view') }] },
|
||||
handler: async (request) => {
|
||||
const { search = '', page = 1, limit = 20, status } = request.query;
|
||||
const filter = { ...cohortScopeFilter(request) };
|
||||
|
|
@ -118,7 +133,7 @@ export function cohortRoutes(server) {
|
|||
method: 'POST',
|
||||
path: '/api/cohorts',
|
||||
options: {
|
||||
pre: [{ method: requirePermission('cohorts', 'add') }],
|
||||
pre: [{ method: requireAnyPermission(['cohorts', 'prt_cohorts'], 'add') }],
|
||||
validate: { payload: cohortPayload },
|
||||
},
|
||||
handler: async (request) => {
|
||||
|
|
@ -133,11 +148,11 @@ export function cohortRoutes(server) {
|
|||
const language = request.payload.language || prtLanguages[0];
|
||||
if (!prtLanguages.includes(language)) throw Boom.badRequest('Select a language this Virtual PRT can teach.');
|
||||
await assertPatients(patients);
|
||||
if (patients.length > (settings.maxPatientsPerCohort || 10)) {
|
||||
if (request.payload.createdAs !== 'manual_cohorts' && patients.length > (settings.maxPatientsPerCohort || 10)) {
|
||||
throw Boom.badRequest(`A cohort can have at most ${settings.maxPatientsPerCohort || 10} patients.`);
|
||||
}
|
||||
try {
|
||||
await assertCohortStartDate(startDate, virtualPrt, language, await chosenWeekdays(scheduleId));
|
||||
await assertCohortStartDate(startDate, virtualPrt, language, startWeekdaysFor(request.payload, await chosenWeekdays(scheduleId)));
|
||||
} catch (err) {
|
||||
throw Boom.badRequest(err.message);
|
||||
}
|
||||
|
|
@ -149,13 +164,30 @@ export function cohortRoutes(server) {
|
|||
language,
|
||||
scheduleId,
|
||||
diseaseType: request.payload.diseaseType || '',
|
||||
createdAs: request.payload.createdAs || 'cohorts',
|
||||
sixDayWeek: Boolean(request.payload.sixDayWeek),
|
||||
status: 'active',
|
||||
virtualPrt,
|
||||
patients,
|
||||
durationMinutes,
|
||||
});
|
||||
if (cohort.createdAs === 'manual_cohorts') {
|
||||
let createdIds = [];
|
||||
try {
|
||||
createdIds = await createManualCohortPatients(cohort, request.payload.newPatients, request.auth.credentials.user._id);
|
||||
if (createdIds.length) {
|
||||
cohort.patients = [...(cohort.patients || []), ...createdIds];
|
||||
await cohort.save();
|
||||
}
|
||||
} catch (err) {
|
||||
if (createdIds.length) await OnboardedPatient.deleteMany({ _id: { $in: createdIds } });
|
||||
await Cohort.deleteOne({ _id: cohort._id });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
await syncCohortPatients(cohort, []);
|
||||
await activateManualCohort(cohort);
|
||||
if (cohort.createdAs === 'manual_cohorts') await applyCohortToPatients(cohort);
|
||||
const ready = await Cohort.findById(cohort._id)
|
||||
.populate('virtualPrt', 'firstName lastName email')
|
||||
.populate({ path: 'patients', match: { reviewStatus: 'confirmed' }, select: 'name mobile whatsappOptOut' });
|
||||
|
|
@ -167,7 +199,7 @@ export function cohortRoutes(server) {
|
|||
method: 'GET',
|
||||
path: '/api/cohorts/{id}',
|
||||
options: {
|
||||
pre: [{ method: requirePermission('cohorts', 'view') }],
|
||||
pre: [{ method: requireAnyPermission(['cohorts', 'prt_cohorts'], 'view') }],
|
||||
validate: { params: Joi.object({ id: objectId.required() }) },
|
||||
},
|
||||
handler: async (request) => (await withSessionCounts(await findCohort(request)))[0],
|
||||
|
|
@ -177,7 +209,7 @@ export function cohortRoutes(server) {
|
|||
method: 'GET',
|
||||
path: '/api/cohorts/{id}/sessions',
|
||||
options: {
|
||||
pre: [{ method: requirePermission('cohorts', 'view') }],
|
||||
pre: [{ method: requireAnyPermission(['cohorts', 'prt_cohorts'], 'view') }],
|
||||
validate: {
|
||||
params: Joi.object({ id: objectId.required() }),
|
||||
query: Joi.object({ count: Joi.number().min(1).max(60) }),
|
||||
|
|
@ -197,7 +229,7 @@ export function cohortRoutes(server) {
|
|||
method: 'PATCH',
|
||||
path: '/api/cohorts/{id}',
|
||||
options: {
|
||||
pre: [{ method: requirePermission('cohorts', 'edit') }],
|
||||
pre: [{ method: requireAnyPermission(['cohorts', 'prt_cohorts'], 'edit') }],
|
||||
validate: {
|
||||
params: Joi.object({ id: objectId.required() }),
|
||||
payload: cohortPayload,
|
||||
|
|
@ -226,11 +258,11 @@ export function cohortRoutes(server) {
|
|||
const language = request.payload.language || prtLanguages[0];
|
||||
if (!prtLanguages.includes(language)) throw Boom.badRequest('Select a language this Virtual PRT can teach.');
|
||||
await assertPatients(patients);
|
||||
if (patients.length > (settings.maxPatientsPerCohort || 10)) {
|
||||
if (request.payload.createdAs !== 'manual_cohorts' && patients.length > (settings.maxPatientsPerCohort || 10)) {
|
||||
throw Boom.badRequest(`A cohort can have at most ${settings.maxPatientsPerCohort || 10} patients.`);
|
||||
}
|
||||
try {
|
||||
await assertCohortStartDate(startDate, virtualPrt, language, await chosenWeekdays(scheduleId));
|
||||
await assertCohortStartDate(startDate, virtualPrt, language, startWeekdaysFor(request.payload, await chosenWeekdays(scheduleId)));
|
||||
} catch (err) {
|
||||
throw Boom.badRequest(err.message);
|
||||
}
|
||||
|
|
@ -240,10 +272,17 @@ export function cohortRoutes(server) {
|
|||
cohort.language = language;
|
||||
cohort.scheduleId = scheduleId;
|
||||
cohort.diseaseType = request.payload.diseaseType || cohort.diseaseType || '';
|
||||
if (request.payload.createdAs) cohort.createdAs = request.payload.createdAs;
|
||||
if (request.payload.sixDayWeek !== undefined) cohort.sixDayWeek = Boolean(request.payload.sixDayWeek);
|
||||
cohort.virtualPrt = virtualPrt;
|
||||
cohort.patients = patients;
|
||||
cohort.durationMinutes = durationMinutes;
|
||||
if (cohort.status === 'proposed') cohort.status = 'active';
|
||||
const manual = (cohort.createdAs || request.payload.createdAs) === 'manual_cohorts';
|
||||
if (manual) {
|
||||
const createdIds = await createManualCohortPatients(cohort, request.payload.newPatients, request.auth.credentials.user._id);
|
||||
if (createdIds.length) cohort.patients = [...patients, ...createdIds];
|
||||
}
|
||||
await cohort.save();
|
||||
await syncCohortPatients(cohort, previous);
|
||||
const { onCohortUpdated } = await import('../services/whatsapp.js');
|
||||
|
|
@ -253,6 +292,7 @@ export function cohortRoutes(server) {
|
|||
if (scheduleChanged) await rescheduleOpenSessions(cohort);
|
||||
else if (await CohortSession.countDocuments({ cohort: cohort._id })) await ensureSessionRecords(cohort);
|
||||
else await persistCohortSessions(cohort);
|
||||
if ((cohort.createdAs || '') === 'manual_cohorts') await applyCohortToPatients(cohort);
|
||||
const ready = await Cohort.findById(cohort._id)
|
||||
.populate('virtualPrt', 'firstName lastName email')
|
||||
.populate({ path: 'patients', match: { reviewStatus: 'confirmed' }, select: 'name mobile whatsappOptOut' });
|
||||
|
|
@ -264,7 +304,7 @@ export function cohortRoutes(server) {
|
|||
method: 'DELETE',
|
||||
path: '/api/cohorts/{id}',
|
||||
options: {
|
||||
pre: [{ method: requirePermission('cohorts', 'delete') }],
|
||||
pre: [{ method: requireAnyPermission(['cohorts', 'prt_cohorts'], 'delete') }],
|
||||
validate: { params: Joi.object({ id: objectId.required() }) },
|
||||
},
|
||||
handler: async (request) => {
|
||||
|
|
@ -280,7 +320,7 @@ export function cohortRoutes(server) {
|
|||
method: 'POST',
|
||||
path: '/api/cohorts/{id}/whatsapp/retry',
|
||||
options: {
|
||||
pre: [{ method: requirePermission('cohorts', 'edit') }],
|
||||
pre: [{ method: requireAnyPermission(['cohorts', 'prt_cohorts'], 'edit') }],
|
||||
validate: { params: Joi.object({ id: objectId.required() }) },
|
||||
},
|
||||
handler: async (request) => {
|
||||
|
|
@ -297,7 +337,7 @@ export function cohortRoutes(server) {
|
|||
method: 'POST',
|
||||
path: '/api/cohorts/{id}/complete',
|
||||
options: {
|
||||
pre: [{ method: requirePermission('cohorts', 'edit') }],
|
||||
pre: [{ method: requireAnyPermission(['cohorts', 'prt_cohorts'], 'edit') }],
|
||||
validate: { params: Joi.object({ id: objectId.required() }) },
|
||||
},
|
||||
handler: async (request) => {
|
||||
|
|
@ -322,7 +362,7 @@ export function virtualPrtRoutes(server) {
|
|||
server.route({
|
||||
method: 'GET',
|
||||
path: '/api/users/virtual-prts',
|
||||
options: { pre: [{ method: requirePermission('cohorts', 'view') }] },
|
||||
options: { pre: [{ method: requireAnyPermission(['cohorts', 'prt_cohorts'], 'view') }] },
|
||||
handler: async () => {
|
||||
const role = await Role.findOne({ systemKey: 'educator' });
|
||||
if (!role) return { items: [] };
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { Cohort } from '../models/Cohort.js';
|
|||
import { User } from '../models/User.js';
|
||||
import { toOnboardedPatientDto, toCohortDto } from '../utils/serialize.js';
|
||||
import { saveOnboardingScreenshot, screenshotPath, safeDownloadName } from '../services/onboardingUpload.js';
|
||||
import { applyOnboardedPii, hashMobile, hashName, readOnboardedPii } from '../services/cryptoPii.js';
|
||||
import { applyOnboardedPii, hashMobile, hashName, isMobileNumber, readOnboardedPii } from '../services/cryptoPii.js';
|
||||
import { deleteScreenshot, presignGet, s3Enabled } from '../services/s3.js';
|
||||
import { writeAudit } from '../services/audit.js';
|
||||
import { addPatientToCohort, removePatientFromCohort } from '../services/cohorts.js';
|
||||
|
|
@ -116,7 +116,7 @@ function applyMasterPatient(fields, master, settings) {
|
|||
|
||||
function assertIdentity(fields) {
|
||||
if (!fields.name) throw Boom.badRequest('Name is required');
|
||||
if (!fields.mobile) throw Boom.badRequest('Mobile is required');
|
||||
if (!isMobileNumber(fields.mobile)) throw Boom.badRequest('Mobile must be 10 digits.');
|
||||
}
|
||||
|
||||
function assertPlacement(fields, settings) {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { Patient } from '../models/Patient.js';
|
|||
import { Session } from '../models/Session.js';
|
||||
import { User } from '../models/User.js';
|
||||
import { toPatientDto, toSessionDto } from '../utils/serialize.js';
|
||||
import { isMobileNumber } from '../services/cryptoPii.js';
|
||||
import { importWorkbook, payloadToBuffer } from '../services/importPatients.js';
|
||||
|
||||
const objectId = Joi.string().hex().length(24);
|
||||
|
|
@ -31,7 +32,8 @@ const patientPayload = Joi.object({
|
|||
|
||||
function applyPatientFields(patient, body, canAssign) {
|
||||
patient.name = body.name;
|
||||
patient.mobile = body.mobile || '';
|
||||
if (body.mobile && !isMobileNumber(body.mobile)) throw Boom.badRequest('Mobile must be 10 digits.');
|
||||
patient.mobile = body.mobile ? String(body.mobile).trim() : '';
|
||||
patient.age = body.age ?? null;
|
||||
patient.gender = body.gender || '';
|
||||
patient.lungCondition = body.lungCondition || '';
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { toUserDto, userLanguages } from '../utils/serialize.js';
|
|||
import { flagPrtResignation } from '../services/matching.js';
|
||||
import { writeAudit } from '../services/audit.js';
|
||||
import { revokeUserRefreshSessions } from '../services/sessionCookies.js';
|
||||
import { isMobileNumber } from '../services/cryptoPii.js';
|
||||
|
||||
function normalizeLanguages(payload) {
|
||||
const list = Array.isArray(payload?.languages)
|
||||
|
|
@ -122,6 +123,7 @@ export function userRoutes(server) {
|
|||
}
|
||||
const email = request.payload.email.toLowerCase().trim();
|
||||
if (await User.findOne({ email })) throw Boom.conflict('Email already registered');
|
||||
if (request.payload.phone && !isMobileNumber(request.payload.phone)) throw Boom.badRequest('Mobile must be 10 digits.');
|
||||
const user = await User.create({
|
||||
firstName: request.payload.firstName,
|
||||
lastName: request.payload.lastName,
|
||||
|
|
@ -193,6 +195,9 @@ export function userRoutes(server) {
|
|||
}
|
||||
if (body.firstName) user.firstName = body.firstName;
|
||||
if (body.lastName) user.lastName = body.lastName;
|
||||
if (body.phone != null && String(body.phone).trim() && !isMobileNumber(body.phone)) {
|
||||
throw Boom.badRequest('Mobile must be 10 digits.');
|
||||
}
|
||||
if (body.phone != null) user.phone = body.phone;
|
||||
if (body.languages != null || body.language != null) {
|
||||
const languages = normalizeLanguages(body);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { Cohort } from '../models/Cohort.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 Boom from '@hapi/boom';
|
||||
|
||||
async function safeWhatsapp(label, fn) {
|
||||
try {
|
||||
|
|
@ -70,3 +73,63 @@ export async function releaseCohortPatients(cohort) {
|
|||
const previous = (cohort.patients || []).map((p) => String(p._id || p));
|
||||
await syncCohortPatients({ ...cohort.toObject?.() || cohort, patients: [] }, previous);
|
||||
}
|
||||
|
||||
// Cohort placement copied onto each patient so the record matches the cohort.
|
||||
export function cohortPatientFields(cohort) {
|
||||
const start = toYmd(cohort.startDate);
|
||||
return {
|
||||
matchingStatus: 'assigned',
|
||||
assignedEducator: cohort.virtualPrt?._id || cohort.virtualPrt,
|
||||
cohort: cohort._id,
|
||||
slot: cohort.slot || '',
|
||||
preferredWeekday: start ? weekdayOf(start) : null,
|
||||
sessionDate: cohort.startDate || null,
|
||||
sessionLanguage: cohort.language || '',
|
||||
diseaseType: cohort.diseaseType || '',
|
||||
};
|
||||
}
|
||||
|
||||
export async function applyCohortToPatients(cohort) {
|
||||
const ids = (cohort.patients || []).map((item) => item._id || item).filter(Boolean);
|
||||
if (!ids.length || !cohort._id) return;
|
||||
await OnboardedPatient.updateMany({ _id: { $in: ids } }, { $set: cohortPatientFields(cohort) });
|
||||
}
|
||||
|
||||
// Creates confirmed patients for a manual cohort and returns their ids.
|
||||
export async function createManualCohortPatients(cohort, rows, userId) {
|
||||
const list = (Array.isArray(rows) ? rows : []).map((row) => ({
|
||||
name: String(row?.name || '').trim(),
|
||||
mobile: String(row?.mobile || '').trim(),
|
||||
age: row?.age ?? '',
|
||||
gender: String(row?.gender || '').trim(),
|
||||
whatsappOptOut: Boolean(row?.whatsappOptOut),
|
||||
})).filter((row) => row.name || row.mobile);
|
||||
if (!list.length) return [];
|
||||
if (list.some((row) => !row.name || !isMobileNumber(row.mobile))) throw Boom.badRequest('Each patient needs a name and a 10 digit mobile.');
|
||||
const hashes = list.map((row) => hashMobile(row.mobile));
|
||||
if (hashes.some((hash) => !hash)) throw Boom.badRequest('Each patient needs a mobile number.');
|
||||
if (new Set(hashes).size !== hashes.length) throw Boom.badRequest('Each patient needs a different mobile.');
|
||||
const taken = await OnboardedPatient.find({ mobileHash: { $in: hashes } }).select('_id');
|
||||
if (taken.length) throw Boom.badRequest('A patient with that mobile is already in the system.');
|
||||
const placement = cohortPatientFields(cohort);
|
||||
const created = [];
|
||||
try {
|
||||
for (const row of list) {
|
||||
const doc = await OnboardedPatient.create({
|
||||
...applyOnboardedPii({}, row),
|
||||
...placement,
|
||||
paymentStatus: 'Unpaid',
|
||||
onboardedBy: userId,
|
||||
reviewStatus: 'confirmed',
|
||||
reviewedBy: userId,
|
||||
reviewedAt: new Date(),
|
||||
whatsappOptOut: row.whatsappOptOut,
|
||||
});
|
||||
created.push(doc._id);
|
||||
}
|
||||
} catch (err) {
|
||||
if (created.length) await OnboardedPatient.deleteMany({ _id: { $in: created } });
|
||||
throw err;
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,6 +70,10 @@ export function hashName(name) {
|
|||
return crypto.createHmac('sha256', pepper()).update(normalized).digest('hex');
|
||||
}
|
||||
|
||||
export function isMobileNumber(mobile) {
|
||||
return /^\d{10}$/.test(String(mobile || '').trim());
|
||||
}
|
||||
|
||||
export function applyOnboardedPii(target, fields) {
|
||||
if (fields.name != null) {
|
||||
target.name = encryptPii(fields.name);
|
||||
|
|
|
|||
|
|
@ -63,11 +63,17 @@ function recordsFor(ids) {
|
|||
return ids.map((patient) => ({ patient, attended: false, pre: {}, post: {} }));
|
||||
}
|
||||
|
||||
// Add PRT cohorts use 60 meetings when all weekdays is yes, otherwise 30.
|
||||
function manualSessionCount(cohort) {
|
||||
if (cohort?.createdAs !== 'manual_cohorts') return 0;
|
||||
return cohort.sixDayWeek ? 60 : 30;
|
||||
}
|
||||
|
||||
// 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 ownCount = await scheduleSessionCount(cohort.scheduleId);
|
||||
const needed = Math.min(Math.max(Number(ownCount || settings.upcomingSessionCount) || 30, 1), 60);
|
||||
const needed = Math.min(Math.max(Number(manualSessionCount(cohort) || 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');
|
||||
|
|
@ -179,7 +185,7 @@ async function syncExistingMeetSeries(cohort, sessions) {
|
|||
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 cap = Math.min(Math.max(Number(manualSessionCount(cohort) || 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');
|
||||
|
|
|
|||
|
|
@ -254,6 +254,8 @@ export function toCohortDto(cohort) {
|
|||
slot: cohort.slot || '',
|
||||
language: cohort.language || '',
|
||||
scheduleId: cohort.scheduleId || '',
|
||||
createdAs: cohort.createdAs || 'cohorts',
|
||||
sixDayWeek: Boolean(cohort.sixDayWeek),
|
||||
diseaseType: cohort.diseaseType || '',
|
||||
status: cohort.status || 'active',
|
||||
needsReassignment: Boolean(cohort.needsReassignment),
|
||||
|
|
|
|||
|
|
@ -24,6 +24,13 @@ export function RequirePermission({ module, action, children }) {
|
|||
return children;
|
||||
}
|
||||
|
||||
export function RequireAnyPermission({ checks, children }) {
|
||||
const { can, loading } = useAuth();
|
||||
if (loading) return <Loader height="40vh" />;
|
||||
if (!checks.some(([moduleKey, action]) => can(moduleKey, action))) return <Navigate to="/error/403" replace />;
|
||||
return children;
|
||||
}
|
||||
|
||||
export function RequireAdmin({ children }) {
|
||||
const { isAdmin, loading } = useAuth();
|
||||
if (loading) return <Loader height="40vh" />;
|
||||
|
|
|
|||
|
|
@ -19,6 +19,14 @@ export function buildCohortName({ slot, language, startDate }) {
|
|||
return `${slot} - ${right}`;
|
||||
}
|
||||
|
||||
export function buildPrtCohortName({ slot, language, weekdays }) {
|
||||
const days = formatWeekdays(weekdays);
|
||||
const right = [language, days].filter(Boolean).join(' ');
|
||||
if (!slot) return right;
|
||||
if (!right) return slot;
|
||||
return `${slot} - ${right}`;
|
||||
}
|
||||
|
||||
export function formatInterval(days) {
|
||||
const value = Number(days) || 2;
|
||||
if (value === 1) return 'every day';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
export function mobileDigits(value) {
|
||||
return String(value || '').replace(/\D/g, '').slice(0, 10);
|
||||
}
|
||||
|
||||
export function isMobileNumber(value) {
|
||||
return /^\d{10}$/.test(String(value || '').trim());
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { lazy } from 'react';
|
||||
import { Navigate } from 'react-router';
|
||||
import MainLayout from '@/layouts/MainLayout';
|
||||
import { GuestRoute, PrivateRoute, RequirePermission, RequireSuperAdmin } from '@/components/PrivateRoute';
|
||||
import { GuestRoute, PrivateRoute, RequireAnyPermission, RequirePermission, RequireSuperAdmin } from '@/components/PrivateRoute';
|
||||
|
||||
const Dashboard = lazy(() => import('@/views/virtualpr/dashboard'));
|
||||
const Patients = lazy(() => import('@/views/virtualpr/patients'));
|
||||
|
|
@ -16,6 +16,8 @@ const Onboarding = lazy(() => import('@/views/virtualpr/onboarding'));
|
|||
const OnboardingForm = lazy(() => import('@/views/virtualpr/onboarding/form'));
|
||||
const Cohorts = lazy(() => import('@/views/virtualpr/cohorts'));
|
||||
const CohortForm = lazy(() => import('@/views/virtualpr/cohorts/form'));
|
||||
const PrtCohortForm = lazy(() => import('@/views/virtualpr/cohorts/prt-form'));
|
||||
const EditCohort = lazy(() => import('@/views/virtualpr/cohorts/edit'));
|
||||
const Holidays = lazy(() => import('@/views/virtualpr/holidays'));
|
||||
const Leaves = lazy(() => import('@/views/virtualpr/leaves'));
|
||||
const Settings = lazy(() => import('@/views/virtualpr/settings'));
|
||||
|
|
@ -79,9 +81,9 @@ export const routes = [
|
|||
{
|
||||
path: '/cohorts',
|
||||
element: (
|
||||
<RequirePermission module="cohorts" action="view">
|
||||
<RequireAnyPermission checks={[['cohorts', 'view'], ['prt_cohorts', 'view']]}>
|
||||
<Cohorts />
|
||||
</RequirePermission>
|
||||
</RequireAnyPermission>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
|
@ -92,12 +94,20 @@ export const routes = [
|
|||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/cohorts/prt/new',
|
||||
element: (
|
||||
<RequirePermission module="prt_cohorts" action="add">
|
||||
<PrtCohortForm />
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/cohorts/:id/edit',
|
||||
element: (
|
||||
<RequirePermission module="cohorts" action="edit">
|
||||
<CohortForm />
|
||||
</RequirePermission>
|
||||
<RequireAnyPermission checks={[['cohorts', 'edit'], ['prt_cohorts', 'edit']]}>
|
||||
<EditCohort />
|
||||
</RequireAnyPermission>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import { Container } from 'react-bootstrap';
|
||||
import PageBreadcrumb from '@/components/PageBreadcrumb';
|
||||
import { useAuth } from '@/context/useAuthContext';
|
||||
import { api } from '@/lib/api';
|
||||
import CohortForm from './form';
|
||||
import PrtCohortForm from './prt-form';
|
||||
|
||||
export default function EditCohort() {
|
||||
const { id } = useParams();
|
||||
const { can } = useAuth();
|
||||
const [cohort, setCohort] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
api(`/api/cohorts/${id}`).then((data) => {
|
||||
if (active) setCohort(data);
|
||||
}).catch((err) => {
|
||||
if (active) setError(err.message);
|
||||
});
|
||||
return () => { active = false; };
|
||||
}, [id]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Container fluid>
|
||||
<PageBreadcrumb title="Edit cohort" subtitle="Cohorts" />
|
||||
<div className="alert alert-danger">{error}</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
if (!cohort) return null;
|
||||
const createdAs = cohort.createdAs || 'cohorts';
|
||||
const allowed = createdAs === 'manual_cohorts' ? can('prt_cohorts', 'edit') : can('cohorts', 'edit');
|
||||
if (!allowed) {
|
||||
return (
|
||||
<Container fluid>
|
||||
<PageBreadcrumb title="Edit cohort" subtitle="Cohorts" />
|
||||
<div className="alert alert-danger">You do not have permission to edit this cohort.</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
if (createdAs === 'manual_cohorts') return <PrtCohortForm />;
|
||||
return <CohortForm />;
|
||||
}
|
||||
|
|
@ -158,7 +158,7 @@ export default function CohortForm() {
|
|||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
const payload = { ...form };
|
||||
const payload = isEdit ? { ...form } : { ...form, createdAs: 'cohorts' };
|
||||
try {
|
||||
if (isEdit) await api(`/api/cohorts/${id}`, { method: 'PATCH', body: payload });
|
||||
else await api('/api/cohorts', { method: 'POST', body: payload });
|
||||
|
|
|
|||
|
|
@ -17,7 +17,11 @@ function formatDate(value) {
|
|||
}
|
||||
|
||||
export default function CohortsPage() {
|
||||
const { can } = useAuth();
|
||||
const { can, user } = useAuth();
|
||||
const isVirtualPrt = user?.role?.systemKey === 'educator';
|
||||
const canFor = (createdAs, action) => (
|
||||
(createdAs || 'cohorts') === 'manual_cohorts' ? can('prt_cohorts', action) : can('cohorts', action)
|
||||
);
|
||||
const [items, setItems] = useState([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(0);
|
||||
|
|
@ -51,7 +55,7 @@ export default function CohortsPage() {
|
|||
columnHelper.accessor('name', {
|
||||
header: 'Cohort',
|
||||
cell: ({ row }) => (
|
||||
can('cohorts', 'edit')
|
||||
canFor(row.original.createdAs, 'edit')
|
||||
? <Link to={`/cohorts/${row.original.id}/edit`} className="link-reset fw-semibold">{row.original.name}</Link>
|
||||
: <span className="fw-semibold">{row.original.name}</span>
|
||||
),
|
||||
|
|
@ -86,8 +90,8 @@ export default function CohortsPage() {
|
|||
header: 'Actions',
|
||||
cell: ({ row }) => (
|
||||
<div className="d-flex gap-1">
|
||||
{can('cohorts', 'edit') ? <Link className="btn btn-default btn-icon btn-sm" to={`/cohorts/${row.original.id}/edit`}><TbEdit className="fs-lg" /></Link> : null}
|
||||
{can('cohorts', 'delete') ? (
|
||||
{canFor(row.original.createdAs, 'edit') ? <Link className="btn btn-default btn-icon btn-sm" to={`/cohorts/${row.original.id}/edit`}><TbEdit className="fs-lg" /></Link> : null}
|
||||
{canFor(row.original.createdAs, 'delete') ? (
|
||||
<Button variant="default" size="sm" className="btn-icon" onClick={() => setDeleteId(row.original.id)}>
|
||||
<TbTrash className="fs-lg" />
|
||||
</Button>
|
||||
|
|
@ -127,7 +131,12 @@ export default function CohortsPage() {
|
|||
<option value="active">Active</option>
|
||||
</select>
|
||||
</form>
|
||||
{can('cohorts', 'add') ? (
|
||||
{can('prt_cohorts', 'add') ? (
|
||||
<Link className="btn btn-success flex-shrink-0" to="/cohorts/prt/new">
|
||||
<TbPlus className="me-1" /> Add PRT cohorts
|
||||
</Link>
|
||||
) : null}
|
||||
{can('cohorts', 'add') && !isVirtualPrt ? (
|
||||
<Link className="btn btn-success flex-shrink-0" to="/cohorts/new">
|
||||
<TbPlus className="me-1" /> New cohort
|
||||
</Link>
|
||||
|
|
@ -200,7 +209,7 @@ export default function CohortsPage() {
|
|||
{!(waCohort?.patients || []).length ? <div className="text-muted">No patients on this cohort.</div> : null}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
{can('cohorts', 'edit') ? (
|
||||
{canFor(waCohort?.createdAs, 'edit') ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline-secondary"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,375 @@
|
|||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router';
|
||||
import { Button, Card, CardBody, Col, Container, FormCheck, FormControl, FormLabel, Row } from 'react-bootstrap';
|
||||
import { TbPlus, TbTrash } from 'react-icons/tb';
|
||||
import DateField from '@/components/DateField';
|
||||
import OptionsSelect from '@/components/OptionsSelect';
|
||||
import PageBreadcrumb from '@/components/PageBreadcrumb';
|
||||
import SlotSelect from '@/components/SlotSelect';
|
||||
import { useAuth } from '@/context/useAuthContext';
|
||||
import { formatInterval, useSettings } from '@/hooks/useSettings';
|
||||
import { api } from '@/lib/api';
|
||||
import { isMobileNumber, mobileDigits } from '@/lib/mobile';
|
||||
|
||||
function prtLanguages(user) {
|
||||
if (Array.isArray(user?.languages) && user.languages.length) return user.languages;
|
||||
return user?.language ? [user.language] : [];
|
||||
}
|
||||
|
||||
const emptyPatient = { name: '', mobile: '', age: '', gender: '' };
|
||||
|
||||
function blankRow() {
|
||||
return { key: `${Date.now()}-${Math.random()}`, id: '', existing: false, ...emptyPatient };
|
||||
}
|
||||
|
||||
function toYmd(date) {
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
||||
}
|
||||
|
||||
// True when this calendar day can be the start date for the yes or no choice.
|
||||
function dateAllowed(date, sixDayWeek) {
|
||||
const day = date.getDay();
|
||||
if (day === 0) return false;
|
||||
if (sixDayWeek === 'yes') return true;
|
||||
if (sixDayWeek === 'no') return day === 1 || day === 2;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Meeting dates from the start date, using the normal repeat. Sunday is skipped.
|
||||
function previewSessionDates(startDate, interval, count) {
|
||||
const needed = Math.min(Math.max(Number(count) || 30, 1), 60);
|
||||
const step = Math.max(1, Number(interval) || 2);
|
||||
const cursor = new Date(`${String(startDate).slice(0, 10)}T00:00:00`);
|
||||
if (!startDate || Number.isNaN(cursor.getTime())) return [];
|
||||
const dates = [];
|
||||
for (let guard = 0; guard < 400 && dates.length < needed; guard += 1) {
|
||||
if (cursor.getDay() !== 0) dates.push(toYmd(cursor));
|
||||
cursor.setDate(cursor.getDate() + step);
|
||||
}
|
||||
return dates;
|
||||
}
|
||||
|
||||
export default function PrtCohortForm() {
|
||||
const { id } = useParams();
|
||||
const isEdit = Boolean(id);
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const { settings } = useSettings();
|
||||
const isVirtualPrt = user?.role?.systemKey === 'educator';
|
||||
const [prts, setPrts] = useState([]);
|
||||
const [virtualPrt, setVirtualPrt] = useState('');
|
||||
const [slot, setSlot] = useState('');
|
||||
const [sixDayWeek, setSixDayWeek] = useState('');
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [language, setLanguage] = useState('');
|
||||
const [diseaseType, setDiseaseType] = useState('');
|
||||
const [rows, setRows] = useState([]);
|
||||
const [error, setError] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const meetingCount = sixDayWeek === 'yes' ? 60 : 30;
|
||||
const interval = settings?.repeatIntervalDays || 2;
|
||||
const selectedPrt = isVirtualPrt ? user : prts.find((item) => item.id === virtualPrt);
|
||||
const languages = prtLanguages(selectedPrt);
|
||||
const prtId = isVirtualPrt ? user?.id : virtualPrt;
|
||||
const chosenLanguage = languages.includes(language) ? language : (languages[0] || '');
|
||||
const sessions = useMemo(
|
||||
() => previewSessionDates(startDate, interval, meetingCount),
|
||||
[startDate, interval, meetingCount],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVirtualPrt) return undefined;
|
||||
let active = true;
|
||||
api('/api/users/virtual-prts').then((data) => {
|
||||
if (active) setPrts(data.items || []);
|
||||
}).catch(() => {
|
||||
if (active) setPrts([]);
|
||||
});
|
||||
return () => { active = false; };
|
||||
}, [isVirtualPrt]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEdit) return undefined;
|
||||
let active = true;
|
||||
api(`/api/cohorts/${id}`).then((cohort) => {
|
||||
if (!active) return;
|
||||
setSlot(cohort.slot || '');
|
||||
setStartDate(cohort.startDate ? String(cohort.startDate).slice(0, 10) : '');
|
||||
setLanguage(cohort.language || '');
|
||||
setDiseaseType(cohort.diseaseType || '');
|
||||
setVirtualPrt(cohort.virtualPrt?.id || '');
|
||||
setSixDayWeek(cohort.sixDayWeek ? 'yes' : 'no');
|
||||
setRows((cohort.patients || []).map((patient) => ({
|
||||
key: patient.id,
|
||||
id: patient.id,
|
||||
existing: true,
|
||||
name: patient.name || '',
|
||||
mobile: patient.mobile || '',
|
||||
age: '',
|
||||
gender: '',
|
||||
})));
|
||||
}).catch((err) => {
|
||||
if (active) setError(err.message);
|
||||
});
|
||||
return () => { active = false; };
|
||||
}, [id, isEdit]);
|
||||
|
||||
const chooseSixDay = (value) => {
|
||||
setSixDayWeek(value);
|
||||
if (!startDate) return;
|
||||
const date = new Date(`${startDate}T00:00:00`);
|
||||
if (!dateAllowed(date, value)) setStartDate('');
|
||||
};
|
||||
|
||||
const updateRow = (key, patch) => {
|
||||
setRows((current) => current.map((row) => (row.key === key ? { ...row, ...patch } : row)));
|
||||
};
|
||||
|
||||
const save = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
if (!prtId) {
|
||||
setError('Select a Virtual PRT.');
|
||||
return;
|
||||
}
|
||||
if (sixDayWeek !== 'yes' && sixDayWeek !== 'no') {
|
||||
setError('Choose yes or no for all weekdays.');
|
||||
return;
|
||||
}
|
||||
if (!startDate) {
|
||||
setError('Select a start date.');
|
||||
return;
|
||||
}
|
||||
if (!chosenLanguage) {
|
||||
setError(isVirtualPrt ? 'Add a language on your profile before creating a cohort.' : 'Select a language this Virtual PRT can teach.');
|
||||
return;
|
||||
}
|
||||
const started = rows.filter((row) => !row.existing && (row.name.trim() || row.mobile.trim() || String(row.age || '').trim() || row.gender));
|
||||
const badRow = started.findIndex((row) => !row.name.trim() || !isMobileNumber(row.mobile));
|
||||
if (badRow >= 0) {
|
||||
setError(`Row ${rows.indexOf(started[badRow]) + 1} needs a name and a 10 digit mobile.`);
|
||||
return;
|
||||
}
|
||||
const fresh = started.map((row) => ({
|
||||
name: row.name.trim(),
|
||||
mobile: row.mobile.trim(),
|
||||
age: row.age === '' || row.age == null ? '' : row.age,
|
||||
gender: row.gender || '',
|
||||
whatsappOptOut: false,
|
||||
}));
|
||||
setSaving(true);
|
||||
const payload = {
|
||||
startDate,
|
||||
slot,
|
||||
virtualPrt: prtId,
|
||||
language: chosenLanguage,
|
||||
diseaseType,
|
||||
patients: rows.filter((row) => row.existing && row.id).map((row) => row.id),
|
||||
newPatients: fresh,
|
||||
scheduleId: '',
|
||||
createdAs: 'manual_cohorts',
|
||||
sixDayWeek: sixDayWeek === 'yes',
|
||||
};
|
||||
try {
|
||||
if (isEdit) await api(`/api/cohorts/${id}`, { method: 'PATCH', body: payload });
|
||||
else await api('/api/cohorts', { method: 'POST', body: payload });
|
||||
navigate('/cohorts');
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container fluid>
|
||||
<PageBreadcrumb title={isEdit ? 'Edit cohort' : 'Add PRT cohorts'} subtitle="Cohorts" />
|
||||
<Card>
|
||||
<CardBody>
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
<form onSubmit={save}>
|
||||
<Row className="g-3">
|
||||
{!isVirtualPrt ? (
|
||||
<Col md={6}>
|
||||
<FormLabel>Virtual PRT</FormLabel>
|
||||
<select
|
||||
className="form-select"
|
||||
value={virtualPrt}
|
||||
onChange={(e) => { setVirtualPrt(e.target.value); setLanguage(''); }}
|
||||
required
|
||||
>
|
||||
<option value="">Select PRT</option>
|
||||
{prts.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.firstName} {item.lastName}{prtLanguages(item).length ? ` (${prtLanguages(item).join(', ')})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Col>
|
||||
) : null}
|
||||
<Col md={3}>
|
||||
<FormLabel>Slot</FormLabel>
|
||||
<SlotSelect value={slot} onChange={setSlot} required />
|
||||
</Col>
|
||||
<Col md={3}>
|
||||
<FormLabel>All weekdays</FormLabel>
|
||||
<div className="d-flex gap-3 pt-2">
|
||||
<FormCheck
|
||||
type="radio"
|
||||
name="sixDayWeek"
|
||||
id="six-day-yes"
|
||||
label="Yes"
|
||||
checked={sixDayWeek === 'yes'}
|
||||
onChange={() => chooseSixDay('yes')}
|
||||
/>
|
||||
<FormCheck
|
||||
type="radio"
|
||||
name="sixDayWeek"
|
||||
id="six-day-no"
|
||||
label="No"
|
||||
checked={sixDayWeek === 'no'}
|
||||
onChange={() => chooseSixDay('no')}
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
<Col md={3}>
|
||||
<FormLabel>Start date</FormLabel>
|
||||
<DateField
|
||||
value={startDate}
|
||||
onChange={setStartDate}
|
||||
required
|
||||
disabled={!sixDayWeek}
|
||||
filterDate={(date) => dateAllowed(date, sixDayWeek)}
|
||||
placeholder={sixDayWeek === 'yes' ? 'Any day except Sunday' : (sixDayWeek === 'no' ? 'Monday or Tuesday' : 'Choose all weekdays first')}
|
||||
/>
|
||||
</Col>
|
||||
<div className="w-100" />
|
||||
<Col md={6}>
|
||||
<FormLabel>Category</FormLabel>
|
||||
<select className="form-select" value={diseaseType} onChange={(e) => setDiseaseType(e.target.value)}>
|
||||
<option value="">Any / not set</option>
|
||||
{(settings?.diseaseTypes || []).map((item) => (
|
||||
<option key={item} value={item}>{item}</option>
|
||||
))}
|
||||
</select>
|
||||
</Col>
|
||||
<Col md={6}>
|
||||
<FormLabel>Language</FormLabel>
|
||||
<select
|
||||
className="form-select"
|
||||
value={chosenLanguage}
|
||||
onChange={(e) => setLanguage(e.target.value)}
|
||||
required
|
||||
disabled={!languages.length}
|
||||
>
|
||||
<option value="">
|
||||
{languages.length ? 'Select language' : (isVirtualPrt ? 'Add a language on your profile' : (prtId ? 'This Virtual PRT has no language' : 'Select a Virtual PRT first'))}
|
||||
</option>
|
||||
{languages.map((item) => (
|
||||
<option key={item} value={item}>{item}</option>
|
||||
))}
|
||||
</select>
|
||||
</Col>
|
||||
<Col xs={12}>
|
||||
<div className="d-flex justify-content-between align-items-center mb-2">
|
||||
<FormLabel className="mb-0">Patients</FormLabel>
|
||||
<Button type="button" variant="light" onClick={() => setRows((current) => [...current, blankRow()])}>
|
||||
<TbPlus className="me-1" /> Add patient
|
||||
</Button>
|
||||
</div>
|
||||
{rows.length ? (
|
||||
<>
|
||||
<div className="form-text mb-2">Fill one row per patient. Name and a 10 digit mobile are required. Empty rows are skipped. Save cohort creates them.</div>
|
||||
<div className="table-responsive border rounded">
|
||||
<table className="table table-sm align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Mobile</th>
|
||||
<th style={{ width: 110 }}>Age</th>
|
||||
<th style={{ width: 160 }}>Gender</th>
|
||||
<th style={{ width: 48 }} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.key}>
|
||||
<td>
|
||||
<FormControl
|
||||
value={row.name}
|
||||
readOnly={row.existing}
|
||||
onChange={(e) => updateRow(row.key, { name: e.target.value })}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<FormControl
|
||||
inputMode="numeric"
|
||||
maxLength={10}
|
||||
value={row.mobile}
|
||||
readOnly={row.existing}
|
||||
onChange={(e) => updateRow(row.key, { mobile: mobileDigits(e.target.value) })}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<FormControl
|
||||
type="number"
|
||||
min={0}
|
||||
value={row.age}
|
||||
readOnly={row.existing}
|
||||
onChange={(e) => updateRow(row.key, { age: e.target.value })}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<OptionsSelect
|
||||
value={row.gender}
|
||||
disabled={row.existing}
|
||||
onChange={(gender) => updateRow(row.key, { gender })}
|
||||
options={settings?.genders}
|
||||
placeholder="Select"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline-danger"
|
||||
size="sm"
|
||||
className="btn-icon"
|
||||
onClick={() => setRows((current) => current.filter((item) => item.key !== row.key))}
|
||||
>
|
||||
<TbTrash className="fs-lg" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</Col>
|
||||
</Row>
|
||||
{sessions.length ? (
|
||||
<div className="mt-4">
|
||||
<h6>Sessions from the start date</h6>
|
||||
<p className="text-muted mb-2">
|
||||
{meetingCount} meetings. Repeats {formatInterval(interval)}. Sunday is skipped.
|
||||
{Number(interval) === 2 ? ' A full week has 3 meetings.' : ''}
|
||||
</p>
|
||||
<div className="d-flex flex-wrap gap-2">
|
||||
{sessions.map((date) => (
|
||||
<span key={date} className="badge bg-primary-subtle text-primary">{date} {slot}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="d-flex gap-2 mt-4">
|
||||
<Button type="submit" disabled={saving}>{saving ? 'Saving...' : 'Save cohort'}</Button>
|
||||
<Link className="btn btn-light" to="/cohorts">Cancel</Link>
|
||||
</div>
|
||||
</form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import PageBreadcrumb from '@/components/PageBreadcrumb';
|
|||
import SlotSelect from '@/components/SlotSelect';
|
||||
import { WEEKDAY_LABELS, useSettings } from '@/hooks/useSettings';
|
||||
import { api } from '@/lib/api';
|
||||
import { isMobileNumber, mobileDigits } from '@/lib/mobile';
|
||||
|
||||
const empty = {
|
||||
name: '',
|
||||
|
|
@ -186,6 +187,10 @@ export default function OnboardingForm() {
|
|||
setError('Without a cohort, language, slot, preferred weekday, and category are required.');
|
||||
return;
|
||||
}
|
||||
if (!String(matched?.mobile || '').trim() && !isMobileNumber(form.mobile)) {
|
||||
setError('Mobile must be 10 digits.');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
const body = new FormData();
|
||||
Object.entries(form).forEach(([key, value]) => body.append(key, value ?? ''));
|
||||
|
|
@ -332,7 +337,7 @@ export default function OnboardingForm() {
|
|||
{!String(matched.mobile || '').trim() ? (
|
||||
<Col md={6}>
|
||||
<FormLabel>Mobile <span className="text-danger">*</span></FormLabel>
|
||||
<FormControl value={form.mobile} onChange={(e) => setField('mobile', e.target.value)} required />
|
||||
<FormControl inputMode="numeric" maxLength={10} value={form.mobile} onChange={(e) => setField('mobile', mobileDigits(e.target.value))} required />
|
||||
</Col>
|
||||
) : null}
|
||||
{placementFields}
|
||||
|
|
@ -360,7 +365,7 @@ export default function OnboardingForm() {
|
|||
</Col>
|
||||
<Col md={6}>
|
||||
<FormLabel>Mobile <span className="text-danger">*</span></FormLabel>
|
||||
<FormControl value={form.mobile} onChange={(e) => setField('mobile', e.target.value)} required />
|
||||
<FormControl inputMode="numeric" maxLength={10} value={form.mobile} onChange={(e) => setField('mobile', mobileDigits(e.target.value))} required />
|
||||
</Col>
|
||||
<Col md={3}>
|
||||
<FormLabel>Age</FormLabel>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import PageBreadcrumb from '@/components/PageBreadcrumb';
|
|||
import { useAuth } from '@/context/useAuthContext';
|
||||
import { useSettings } from '@/hooks/useSettings';
|
||||
import { api } from '@/lib/api';
|
||||
import { isMobileNumber, mobileDigits } from '@/lib/mobile';
|
||||
|
||||
const empty = {
|
||||
sourcePatientId: '',
|
||||
|
|
@ -64,6 +65,10 @@ export default function PatientForm() {
|
|||
const save = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
if (String(form.mobile || '').trim() && !isMobileNumber(form.mobile)) {
|
||||
setError('Mobile must be 10 digits.');
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
...form,
|
||||
age: form.age === '' ? null : Number(form.age),
|
||||
|
|
@ -101,7 +106,7 @@ export default function PatientForm() {
|
|||
</Col>
|
||||
<Col md={4}>
|
||||
<FormLabel>Mobile</FormLabel>
|
||||
<FormControl value={form.mobile} onChange={(e) => setField('mobile', e.target.value)} />
|
||||
<FormControl inputMode="numeric" maxLength={10} value={form.mobile} onChange={(e) => setField('mobile', mobileDigits(e.target.value))} />
|
||||
</Col>
|
||||
<Col md={3}>
|
||||
<FormLabel>Age</FormLabel>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import PageBreadcrumb from '@/components/PageBreadcrumb';
|
|||
import { useAuth } from '@/context/useAuthContext';
|
||||
import { useSettings, WEEKDAY_LABELS } from '@/hooks/useSettings';
|
||||
import { api } from '@/lib/api';
|
||||
import { isMobileNumber, mobileDigits } from '@/lib/mobile';
|
||||
|
||||
function pad(n) {
|
||||
return String(n).padStart(2, '0');
|
||||
|
|
@ -165,6 +166,10 @@ export default function ProfilePage() {
|
|||
e.preventDefault();
|
||||
setProfileError('');
|
||||
setProfileOk('');
|
||||
if (String(profile.phone || '').trim() && !isMobileNumber(profile.phone)) {
|
||||
setProfileError('Mobile must be 10 digits.');
|
||||
return;
|
||||
}
|
||||
setSavingProfile(true);
|
||||
try {
|
||||
await api('/api/auth/profile', {
|
||||
|
|
@ -237,7 +242,7 @@ export default function ProfilePage() {
|
|||
</Col>
|
||||
<Col md={6}>
|
||||
<FormLabel>Phone</FormLabel>
|
||||
<FormControl value={profile.phone} onChange={(e) => setProfile((p) => ({ ...p, phone: e.target.value }))} />
|
||||
<FormControl inputMode="numeric" maxLength={10} value={profile.phone} onChange={(e) => setProfile((p) => ({ ...p, phone: mobileDigits(e.target.value) }))} />
|
||||
</Col>
|
||||
<Col md={6}>
|
||||
<FormLabel>Role</FormLabel>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import TablePagination from '@/components/table/TablePagination';
|
|||
import { useAuth } from '@/context/useAuthContext';
|
||||
import { useSettings } from '@/hooks/useSettings';
|
||||
import { api } from '@/lib/api';
|
||||
import { isMobileNumber, mobileDigits } from '@/lib/mobile';
|
||||
|
||||
const emptyForm = { firstName: '', lastName: '', email: '', phone: '', password: '', role: '', languages: [], specializations: [], availableSlots: [], availableStartWeekdays: [], maxCohorts: '', isActive: true };
|
||||
|
||||
|
|
@ -84,6 +85,10 @@ export default function UsersPage() {
|
|||
setError('Select at least one language for a Virtual PRT.');
|
||||
return;
|
||||
}
|
||||
if (String(form.phone || '').trim() && !isMobileNumber(form.phone)) {
|
||||
setError('Mobile must be 10 digits.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload = { ...form, maxCohorts: form.maxCohorts === '' ? null : Number(form.maxCohorts) };
|
||||
if (editing && !payload.password) delete payload.password;
|
||||
|
|
@ -211,7 +216,7 @@ export default function UsersPage() {
|
|||
</Col>
|
||||
<Col md={6}>
|
||||
<FormLabel>Phone</FormLabel>
|
||||
<FormControl value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} />
|
||||
<FormControl inputMode="numeric" maxLength={10} value={form.phone} onChange={(e) => setForm({ ...form, phone: mobileDigits(e.target.value) })} />
|
||||
</Col>
|
||||
<Col md={6}>
|
||||
<FormLabel>Role</FormLabel>
|
||||
|
|
|
|||
Loading…
Reference in New Issue