Changes
This commit is contained in:
parent
4acc4fb84c
commit
2bbfc622d9
|
|
@ -30,3 +30,6 @@ JWT_EXPIRES_IN=7d
|
||||||
|
|
||||||
# Max survey submits per contact_id per hour
|
# Max survey submits per contact_id per hour
|
||||||
SURVEY_RATE_LIMIT_PER_HOUR=60
|
SURVEY_RATE_LIMIT_PER_HOUR=60
|
||||||
|
|
||||||
|
# Shared secret for Road bot → POST /api/whatsapp/validate (header X-TGICS-Internal-Key)
|
||||||
|
TGICS_INTERNAL_KEY=
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,19 @@ const initializeDb = async () => {
|
||||||
INDEX idx_surveys_user_contact (user_id, contact_id)
|
INDEX idx_surveys_user_contact (user_id, contact_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
await connection.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS survey_drafts (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
contact_id VARCHAR(128) NOT NULL,
|
||||||
|
user_id VARCHAR(128) NOT NULL,
|
||||||
|
\`partition\` VARCHAR(64) NOT NULL DEFAULT 'rapport-lifemankind',
|
||||||
|
answers_json JSON NOT NULL,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uniq_survey_drafts_contact_user (contact_id, user_id),
|
||||||
|
INDEX idx_survey_drafts_user (user_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
|
`);
|
||||||
} finally {
|
} finally {
|
||||||
connection.release();
|
connection.release();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,14 @@
|
||||||
const { pool } = require('../config/db');
|
const { pool } = require('../config/db');
|
||||||
const { getUsersByRoles, getPositionsByOwner, getEnrolledContacts, getContactById, } = require('../services/rapport');
|
const {
|
||||||
|
getUsersByRoles,
|
||||||
|
getPositionsByOwner,
|
||||||
|
getEnrolledContacts,
|
||||||
|
getContactById,
|
||||||
|
updateContact,
|
||||||
|
toDoctorPayload,
|
||||||
|
normalizePhone,
|
||||||
|
} = require('../services/rapport');
|
||||||
|
const { isValidState } = require('../data/indiaGeo');
|
||||||
|
|
||||||
function tmRoleIds() {
|
function tmRoleIds() {
|
||||||
return String(process.env.TGICS_TM_ROLE_IDS || '')
|
return String(process.env.TGICS_TM_ROLE_IDS || '')
|
||||||
|
|
@ -98,6 +107,7 @@ async function getTmUsers(request, h) {
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
name: `${user.firstname || ''} ${user.lastname || ''}`.trim(),
|
name: `${user.firstname || ''} ${user.lastname || ''}`.trim(),
|
||||||
employee_code: user.employee_code || '',
|
employee_code: user.employee_code || '',
|
||||||
|
phone: user.phone || position.phone || '',
|
||||||
designation: position.designation || user.designation || '',
|
designation: position.designation || user.designation || '',
|
||||||
position_name: position.position_name || '',
|
position_name: position.position_name || '',
|
||||||
position_code: position.position_code || '',
|
position_code: position.position_code || '',
|
||||||
|
|
@ -171,13 +181,10 @@ async function getDoctors(request, h) {
|
||||||
|
|
||||||
const items = contacts.map((contact) => {
|
const items = contacts.map((contact) => {
|
||||||
const surveys = surveysByContact.get(contact.id);
|
const surveys = surveysByContact.get(contact.id);
|
||||||
|
const doctor = toDoctorPayload(contact, contact.user_id);
|
||||||
return {
|
return {
|
||||||
contact_id: contact.id,
|
...doctor,
|
||||||
name: contact.name || '',
|
msl_code: contact.msl_code || doctor.wfsa_code || '',
|
||||||
msl_code: contact.msl_code || '',
|
|
||||||
phone: contact.phone || '',
|
|
||||||
email: contact.email || '',
|
|
||||||
user_id: contact.user_id || '',
|
|
||||||
...mrDetails(contact.user_id, positions, contact),
|
...mrDetails(contact.user_id, positions, contact),
|
||||||
total_surveys: surveys ? Number(surveys.total_surveys) || 0 : 0,
|
total_surveys: surveys ? Number(surveys.total_surveys) || 0 : 0,
|
||||||
};
|
};
|
||||||
|
|
@ -257,4 +264,98 @@ async function getPatients(request, h) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { listSurveys, surveyStats, getTmUsers, getDoctors, getPatients };
|
async function updateDoctor(request, h) {
|
||||||
|
try {
|
||||||
|
const contactId = String(request.params.contactId || '').trim();
|
||||||
|
const body = request.payload || {};
|
||||||
|
if (!contactId) {
|
||||||
|
return h.response({ status: 'error', message: 'contactId is required.' }).code(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = String(body.name || '').trim();
|
||||||
|
const phone = normalizePhone(body.phone);
|
||||||
|
const email = String(body.email || '').trim();
|
||||||
|
const age = String(body.age ?? '').replace(/\D+/g, '');
|
||||||
|
const years = String(body.years_of_practice ?? '').replace(/\D+/g, '');
|
||||||
|
const avgPatients = String(body.average_no_of_patients ?? '').replace(/\D+/g, '');
|
||||||
|
const state = String(body.state || '').trim();
|
||||||
|
const city = String(body.city || body.city_town || '').trim();
|
||||||
|
const wfsa = String(body.wfsa_code || body.msl_code || '').replace(/\D+/g, '');
|
||||||
|
const qualification = String(body.qualification || '').trim();
|
||||||
|
const otherQualification = String(body.other_qualification || '').trim();
|
||||||
|
const typeOfPractice = String(body.type_of_practice || '').trim();
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
return h.response({ status: 'error', message: 'Doctor name is required.' }).code(400);
|
||||||
|
}
|
||||||
|
if (!/^[6-9]\d{9}$/.test(phone)) {
|
||||||
|
return h.response({
|
||||||
|
status: 'error',
|
||||||
|
message: 'Phone must be a valid 10-digit mobile number.',
|
||||||
|
}).code(400);
|
||||||
|
}
|
||||||
|
if (!avgPatients) {
|
||||||
|
return h.response({
|
||||||
|
status: 'error',
|
||||||
|
message: 'Average No. of Patients is required.',
|
||||||
|
}).code(400);
|
||||||
|
}
|
||||||
|
if (!state || !isValidState(state)) {
|
||||||
|
return h.response({ status: 'error', message: 'Please select a valid state.' }).code(400);
|
||||||
|
}
|
||||||
|
if (!city) {
|
||||||
|
return h.response({ status: 'error', message: 'Please select a valid city.' }).code(400);
|
||||||
|
}
|
||||||
|
// Non-list cities are allowed for legacy rows already stored on the contact.
|
||||||
|
|
||||||
|
const existing = await getContactById(contactId);
|
||||||
|
if (!existing) {
|
||||||
|
return h.response({ status: 'error', message: 'Doctor not found.' }).code(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
id: contactId,
|
||||||
|
name,
|
||||||
|
msl_code: wfsa || existing.msl_code || '',
|
||||||
|
phone,
|
||||||
|
email,
|
||||||
|
type: 'doctor',
|
||||||
|
tgics_enrolled_doctor: 'Yes',
|
||||||
|
age: age || '',
|
||||||
|
city,
|
||||||
|
state,
|
||||||
|
qualification,
|
||||||
|
other_qualification: otherQualification,
|
||||||
|
years_of_practice: years || '',
|
||||||
|
type_of_practice: typeOfPractice,
|
||||||
|
average_no_of_patients: avgPatients,
|
||||||
|
};
|
||||||
|
if (existing.user_id) {
|
||||||
|
payload.user_id = existing.user_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingPhone = normalizePhone(existing.phone);
|
||||||
|
if (existingPhone && existingPhone !== phone) {
|
||||||
|
payload.tgics_whatsapp_validated = 'No';
|
||||||
|
} else if (existing.tgics_whatsapp_validated !== undefined && existing.tgics_whatsapp_validated !== null) {
|
||||||
|
payload.tgics_whatsapp_validated = existing.tgics_whatsapp_validated;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await updateContact(payload);
|
||||||
|
const doctor = toDoctorPayload(updated || { ...existing, ...payload, id: contactId }, existing.user_id);
|
||||||
|
|
||||||
|
return h.response({
|
||||||
|
status: 'success',
|
||||||
|
data: { doctor },
|
||||||
|
message: 'Doctor updated successfully.',
|
||||||
|
}).code(200);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('updateDoctor error', err);
|
||||||
|
return h.response({
|
||||||
|
status: 'error',
|
||||||
|
message: err.message || 'Unable to update doctor.',
|
||||||
|
}).code(err.statusCode || 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { listSurveys, surveyStats, getTmUsers, getDoctors, getPatients, updateDoctor };
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,15 @@ async function createSurvey(request, h) {
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pool.execute(
|
||||||
|
'DELETE FROM survey_drafts WHERE contact_id = ? AND user_id = ?',
|
||||||
|
[contactId, userId]
|
||||||
|
);
|
||||||
|
} catch (draftErr) {
|
||||||
|
console.warn('createSurvey draft cleanup failed', draftErr.message);
|
||||||
|
}
|
||||||
|
|
||||||
return h.response({
|
return h.response({
|
||||||
status: 'success',
|
status: 'success',
|
||||||
data: { id: result.insertId },
|
data: { id: result.insertId },
|
||||||
|
|
@ -205,4 +214,148 @@ async function listSurveys(request, h) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { createSurvey, getCounts, getCount, listSurveys };
|
function parseDraftAnswers(raw) {
|
||||||
|
if (raw && typeof raw === 'object') return raw;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw || '{}');
|
||||||
|
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDraft(request, h) {
|
||||||
|
const contactId = String(request.query.contact_id || '').trim();
|
||||||
|
const userId = String(request.query.user_id || '').trim();
|
||||||
|
if (!contactId || !userId) {
|
||||||
|
return h.response({
|
||||||
|
status: 'error',
|
||||||
|
message: 'contact_id and user_id are required.',
|
||||||
|
}).code(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const doctorResult = await resolveTgicsDoctor(userId, contactId);
|
||||||
|
if (!doctorResult.ok) {
|
||||||
|
return h.response({ status: 'error', message: doctorResult.message }).code(doctorResult.statusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [rows] = await pool.execute(
|
||||||
|
`SELECT answers_json, updated_at
|
||||||
|
FROM survey_drafts
|
||||||
|
WHERE contact_id = ? AND user_id = ?
|
||||||
|
LIMIT 1`,
|
||||||
|
[contactId, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!rows.length) {
|
||||||
|
return h.response({
|
||||||
|
status: 'success',
|
||||||
|
data: { draft: null },
|
||||||
|
}).code(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
return h.response({
|
||||||
|
status: 'success',
|
||||||
|
data: {
|
||||||
|
draft: {
|
||||||
|
answers: parseDraftAnswers(rows[0].answers_json),
|
||||||
|
updated_at: rows[0].updated_at,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}).code(200);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('getDraft error', err);
|
||||||
|
return h.response({
|
||||||
|
status: 'error',
|
||||||
|
message: err.message || 'Unable to load draft.',
|
||||||
|
}).code(err.statusCode || 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveDraft(request, h) {
|
||||||
|
const payload = request.payload || {};
|
||||||
|
const userId = String(payload.user_id || '').trim();
|
||||||
|
const contactId = String(payload.contact_id || '').trim();
|
||||||
|
const answers = payload.answers;
|
||||||
|
|
||||||
|
if (!userId || !contactId) {
|
||||||
|
return h.response({
|
||||||
|
status: 'error',
|
||||||
|
message: 'user_id and contact_id are required.',
|
||||||
|
}).code(400);
|
||||||
|
}
|
||||||
|
if (!answers || typeof answers !== 'object' || Array.isArray(answers)) {
|
||||||
|
return h.response({
|
||||||
|
status: 'error',
|
||||||
|
message: 'answers object is required.',
|
||||||
|
}).code(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const doctorResult = await resolveTgicsDoctor(userId, contactId);
|
||||||
|
if (!doctorResult.ok) {
|
||||||
|
return h.response({ status: 'error', message: doctorResult.message }).code(doctorResult.statusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.execute(
|
||||||
|
`INSERT INTO survey_drafts (contact_id, user_id, \`partition\`, answers_json)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
answers_json = VALUES(answers_json),
|
||||||
|
\`partition\` = VALUES(\`partition\`),
|
||||||
|
updated_at = CURRENT_TIMESTAMP`,
|
||||||
|
[contactId, userId, PARTITION, JSON.stringify(answers)]
|
||||||
|
);
|
||||||
|
|
||||||
|
return h.response({
|
||||||
|
status: 'success',
|
||||||
|
message: 'Draft saved.',
|
||||||
|
data: { contact_id: contactId },
|
||||||
|
}).code(200);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('saveDraft error', err);
|
||||||
|
return h.response({
|
||||||
|
status: 'error',
|
||||||
|
message: err.message || 'Unable to save draft.',
|
||||||
|
}).code(err.statusCode || 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteDraft(request, h) {
|
||||||
|
const contactId = String(request.query.contact_id || request.payload?.contact_id || '').trim();
|
||||||
|
const userId = String(request.query.user_id || request.payload?.user_id || '').trim();
|
||||||
|
if (!contactId || !userId) {
|
||||||
|
return h.response({
|
||||||
|
status: 'error',
|
||||||
|
message: 'contact_id and user_id are required.',
|
||||||
|
}).code(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pool.execute(
|
||||||
|
'DELETE FROM survey_drafts WHERE contact_id = ? AND user_id = ?',
|
||||||
|
[contactId, userId]
|
||||||
|
);
|
||||||
|
return h.response({
|
||||||
|
status: 'success',
|
||||||
|
message: 'Draft cleared.',
|
||||||
|
}).code(200);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('deleteDraft error', err);
|
||||||
|
return h.response({
|
||||||
|
status: 'error',
|
||||||
|
message: 'Unable to clear draft.',
|
||||||
|
}).code(500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createSurvey,
|
||||||
|
getCounts,
|
||||||
|
getCount,
|
||||||
|
listSurveys,
|
||||||
|
getDraft,
|
||||||
|
saveDraft,
|
||||||
|
deleteDraft,
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,15 @@
|
||||||
const { listSurveys, surveyStats, getTmUsers, getDoctors, getPatients, } = require('../controllers/dashboardController');
|
const {
|
||||||
|
listSurveys,
|
||||||
|
surveyStats,
|
||||||
|
getTmUsers,
|
||||||
|
getDoctors,
|
||||||
|
getPatients,
|
||||||
|
updateDoctor,
|
||||||
|
} = require('../controllers/dashboardController');
|
||||||
const { requireAuth } = require('../middleware/auth');
|
const { requireAuth } = require('../middleware/auth');
|
||||||
|
|
||||||
const authed = (path, handler) => ({
|
const authed = (method, path, handler) => ({
|
||||||
method: 'GET',
|
method,
|
||||||
path,
|
path,
|
||||||
options: {
|
options: {
|
||||||
pre: [{ method: requireAuth }],
|
pre: [{ method: requireAuth }],
|
||||||
|
|
@ -11,9 +18,10 @@ const authed = (path, handler) => ({
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = [
|
module.exports = [
|
||||||
authed('/api/dashboard/stats', surveyStats),
|
authed('GET', '/api/dashboard/stats', surveyStats),
|
||||||
authed('/api/dashboard/surveys', listSurveys),
|
authed('GET', '/api/dashboard/surveys', listSurveys),
|
||||||
authed('/api/dashboard/tm-users', getTmUsers),
|
authed('GET', '/api/dashboard/tm-users', getTmUsers),
|
||||||
authed('/api/dashboard/doctors', getDoctors),
|
authed('GET', '/api/dashboard/doctors', getDoctors),
|
||||||
authed('/api/dashboard/patients', getPatients),
|
authed('PATCH', '/api/dashboard/doctors/{contactId}', updateDoctor),
|
||||||
|
authed('GET', '/api/dashboard/patients', getPatients),
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,12 @@
|
||||||
const { createSurvey, getCounts, getCount, listSurveys } = require('../controllers/surveyController');
|
const {
|
||||||
|
createSurvey,
|
||||||
|
getCounts,
|
||||||
|
getCount,
|
||||||
|
listSurveys,
|
||||||
|
getDraft,
|
||||||
|
saveDraft,
|
||||||
|
deleteDraft,
|
||||||
|
} = require('../controllers/surveyController');
|
||||||
|
|
||||||
module.exports = [
|
module.exports = [
|
||||||
{
|
{
|
||||||
|
|
@ -17,6 +25,30 @@ module.exports = [
|
||||||
},
|
},
|
||||||
handler: listSurveys,
|
handler: listSurveys,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/api/surveys/draft',
|
||||||
|
options: {
|
||||||
|
description: 'Load survey draft for a doctor contact',
|
||||||
|
},
|
||||||
|
handler: getDraft,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
path: '/api/surveys/draft',
|
||||||
|
options: {
|
||||||
|
description: 'Upsert survey draft answers',
|
||||||
|
},
|
||||||
|
handler: saveDraft,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'DELETE',
|
||||||
|
path: '/api/surveys/draft',
|
||||||
|
options: {
|
||||||
|
description: 'Clear survey draft for a doctor contact',
|
||||||
|
},
|
||||||
|
handler: deleteDraft,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: '/api/surveys/counts',
|
path: '/api/surveys/counts',
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,35 @@
|
||||||
const bcrypt = require('bcrypt');
|
const bcrypt = require('bcrypt');
|
||||||
const { pool } = require('./config/db');
|
const { pool } = require('./config/db');
|
||||||
|
|
||||||
|
const DASHBOARD_USERS = [
|
||||||
|
{ email: 'ravi@stratawiz.com', password: 'qwerty', name: 'Ravi', role: 'admin' },
|
||||||
|
{ email: 'projectmanager@crestindia.co.in', password: 'qwerty', name: 'Project Manager', role: 'admin' },
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure the first dashboard admin exists.
|
* Ensure dashboard admins exist. Existing emails are left unchanged.
|
||||||
*/
|
*/
|
||||||
async function seedUsers() {
|
async function seedUsers() {
|
||||||
const email = 'ravi@stratawiz.com';
|
for (const user of DASHBOARD_USERS) {
|
||||||
const password = 'qwerty';
|
const email = user.email.trim().toLowerCase();
|
||||||
const name = 'Ravi';
|
const [existing] = await pool.execute(
|
||||||
const role = 'admin';
|
'SELECT id FROM users WHERE email = ? LIMIT 1',
|
||||||
|
[email]
|
||||||
|
);
|
||||||
|
|
||||||
const [existing] = await pool.execute(
|
if (existing.length) {
|
||||||
'SELECT id FROM users WHERE email = ? LIMIT 1',
|
console.log('Seed user already exists:', email);
|
||||||
[email]
|
continue;
|
||||||
);
|
}
|
||||||
|
|
||||||
if (existing.length) {
|
const passwordHash = await bcrypt.hash(user.password, 10);
|
||||||
console.log('Seed user already exists:', email);
|
await pool.execute(
|
||||||
return;
|
`INSERT INTO users (name, email, password_hash, role, status)
|
||||||
|
VALUES (?, ?, ?, ?, 'active')`,
|
||||||
|
[user.name, email, passwordHash, user.role]
|
||||||
|
);
|
||||||
|
console.log('Seeded dashboard user:', email);
|
||||||
}
|
}
|
||||||
|
|
||||||
const passwordHash = await bcrypt.hash(password, 10);
|
|
||||||
await pool.execute(
|
|
||||||
`INSERT INTO users (name, email, password_hash, role, status)
|
|
||||||
VALUES (?, ?, ?, ?, 'active')`,
|
|
||||||
[name, email, passwordHash, role]
|
|
||||||
);
|
|
||||||
console.log('Seeded dashboard user:', email);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { seedUsers };
|
module.exports = { seedUsers };
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ const init = async () => {
|
||||||
...require('./routes/dashboardRoutes'),
|
...require('./routes/dashboardRoutes'),
|
||||||
...require('./routes/doctorRoutes'),
|
...require('./routes/doctorRoutes'),
|
||||||
...require('./routes/surveyRoutes'),
|
...require('./routes/surveyRoutes'),
|
||||||
|
...require('./routes/whatsappRoutes'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await initializeDb();
|
await initializeDb();
|
||||||
|
|
|
||||||
|
|
@ -54,10 +54,16 @@ function isTgicsEnrolled(contact) {
|
||||||
return String(flag).toLowerCase() === 'yes' || flag === true || flag === 1 || flag === '1';
|
return String(flag).toLowerCase() === 'yes' || flag === true || flag === 1 || flag === '1';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isTgicsWhatsappValidated(contact) {
|
||||||
|
const flag = contact?.tgics_whatsapp_validated;
|
||||||
|
if (flag === true || flag === 1 || flag === '1') return true;
|
||||||
|
return String(flag || '').toLowerCase() === 'yes';
|
||||||
|
}
|
||||||
|
|
||||||
function toDoctorPayload(contact, userId) {
|
function toDoctorPayload(contact, userId) {
|
||||||
return {
|
return {
|
||||||
contact_id: contact.id,
|
contact_id: contact.id,
|
||||||
user_id: userId || null,
|
user_id: userId || contact.user_id || null,
|
||||||
name: contact.name || '',
|
name: contact.name || '',
|
||||||
wfsa_code: contact.msl_code || '',
|
wfsa_code: contact.msl_code || '',
|
||||||
phone: contact.phone || '',
|
phone: contact.phone || '',
|
||||||
|
|
@ -65,13 +71,50 @@ function toDoctorPayload(contact, userId) {
|
||||||
age: contact.age || '',
|
age: contact.age || '',
|
||||||
years_of_practice: contact.years_of_practice || '',
|
years_of_practice: contact.years_of_practice || '',
|
||||||
type_of_practice: contact.type_of_practice || '',
|
type_of_practice: contact.type_of_practice || '',
|
||||||
|
average_no_of_patients: contact.average_no_of_patients || '',
|
||||||
city: contact.city || contact.city_town || '',
|
city: contact.city || contact.city_town || '',
|
||||||
state: contact.state || '',
|
state: contact.state || '',
|
||||||
qualification: contact.qualification || '',
|
qualification: contact.qualification || '',
|
||||||
|
other_qualification: contact.other_qualification || '',
|
||||||
tgics_enrolled_doctor: contact.tgics_enrolled_doctor || '',
|
tgics_enrolled_doctor: contact.tgics_enrolled_doctor || '',
|
||||||
|
tgics_whatsapp_validated: isTgicsWhatsappValidated(contact),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a Rapport contact via contact/update.
|
||||||
|
*/
|
||||||
|
async function updateContact(payload) {
|
||||||
|
if (!ACCESS_TOKEN) {
|
||||||
|
const err = new Error('RAPPORT_ACCESS_TOKEN is not configured');
|
||||||
|
err.statusCode = 503;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (!payload?.id) {
|
||||||
|
const err = new Error('Contact id is required');
|
||||||
|
err.statusCode = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(new URL('contact/contact/update', API_BASE).toString(), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: ACCESS_TOKEN,
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok || (body.status && body.status !== 'success')) {
|
||||||
|
const err = new Error(body.message || `Rapport contact update failed (${response.status})`);
|
||||||
|
err.statusCode = response.status === 401 ? 503 : 502;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return body?.data?.item || body?.item || null;
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveTgicsDoctor(userId, contactId) {
|
async function resolveTgicsDoctor(userId, contactId) {
|
||||||
const contact = await getContactById(contactId);
|
const contact = await getContactById(contactId);
|
||||||
if (!contact) {
|
if (!contact) {
|
||||||
|
|
@ -255,6 +298,7 @@ async function getPositionsByOwner({ refresh = false } = {}) {
|
||||||
designation: row.designation || '',
|
designation: row.designation || '',
|
||||||
name: `${info.firstname || ''} ${info.lastname || ''}`.trim(),
|
name: `${info.firstname || ''} ${info.lastname || ''}`.trim(),
|
||||||
employee_code: info.employee_code || '',
|
employee_code: info.employee_code || '',
|
||||||
|
phone: info.phone || '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return byOwner;
|
return byOwner;
|
||||||
|
|
@ -300,7 +344,9 @@ module.exports = {
|
||||||
getPositionsByOwner,
|
getPositionsByOwner,
|
||||||
getEnrolledContacts,
|
getEnrolledContacts,
|
||||||
getContactById,
|
getContactById,
|
||||||
|
updateContact,
|
||||||
isTgicsEnrolled,
|
isTgicsEnrolled,
|
||||||
|
isTgicsWhatsappValidated,
|
||||||
toDoctorPayload,
|
toDoctorPayload,
|
||||||
resolveTgicsDoctor,
|
resolveTgicsDoctor,
|
||||||
resolveTgicsDoctorByPhone,
|
resolveTgicsDoctorByPhone,
|
||||||
|
|
|
||||||
|
|
@ -198,6 +198,12 @@
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.field-hint {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
.field-label {
|
.field-label {
|
||||||
display: block;
|
display: block;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,38 @@ export function fetchDashboardDoctors(refresh = false) {
|
||||||
return request(`/api/dashboard/doctors${refresh ? '?refresh=1' : ''}`);
|
return request(`/api/dashboard/doctors${refresh ? '?refresh=1' : ''}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function updateDashboardDoctor(contactId, payload) {
|
||||||
|
return request(`/api/dashboard/doctors/${encodeURIComponent(contactId)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchSurveyDraft(contactId, userId) {
|
||||||
|
const qs = new URLSearchParams({
|
||||||
|
contact_id: contactId,
|
||||||
|
user_id: userId,
|
||||||
|
});
|
||||||
|
return request(`/api/surveys/draft?${qs.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveSurveyDraft(payload) {
|
||||||
|
return request('/api/surveys/draft', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteSurveyDraft(contactId, userId) {
|
||||||
|
const qs = new URLSearchParams({
|
||||||
|
contact_id: contactId,
|
||||||
|
user_id: userId,
|
||||||
|
});
|
||||||
|
return request(`/api/surveys/draft?${qs.toString()}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function fetchDashboardPatients(refresh = false) {
|
export function fetchDashboardPatients(refresh = false) {
|
||||||
return request(`/api/dashboard/patients${refresh ? '?refresh=1' : ''}`);
|
return request(`/api/dashboard/patients${refresh ? '?refresh=1' : ''}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,29 @@
|
||||||
import { useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { fetchDashboardDoctors } from '../api';
|
import { fetchDashboardDoctors, updateDashboardDoctor } from '../api';
|
||||||
|
import { INDIA_STATES, citiesForState } from '../indiaGeo';
|
||||||
import DashboardLoading from './DashboardLoading';
|
import DashboardLoading from './DashboardLoading';
|
||||||
import exportCsv from './exportCsv';
|
import exportCsv from './exportCsv';
|
||||||
import useDashboardData, { matchesQuery } from './useDashboardData';
|
import useDashboardData, { matchesQuery } from './useDashboardData';
|
||||||
|
|
||||||
const SURVEY_ORIGIN = 'https://tgics.cttr.in';
|
const SURVEY_ORIGIN = 'https://tgics.cttr.in';
|
||||||
|
|
||||||
|
const QUALIFICATIONS = [
|
||||||
|
'MBBS',
|
||||||
|
'MD',
|
||||||
|
'MS',
|
||||||
|
'DNB',
|
||||||
|
'DM',
|
||||||
|
'MCh',
|
||||||
|
'Others',
|
||||||
|
];
|
||||||
|
|
||||||
|
const PRACTICE_TYPES = [
|
||||||
|
'Clinic',
|
||||||
|
'Hospital',
|
||||||
|
'Both',
|
||||||
|
];
|
||||||
|
|
||||||
function doctorWhatsappLink(row) {
|
function doctorWhatsappLink(row) {
|
||||||
const userId = String(row.user_id || '').trim();
|
const userId = String(row.user_id || '').trim();
|
||||||
const contactId = String(row.contact_id || '').trim();
|
const contactId = String(row.contact_id || '').trim();
|
||||||
|
|
@ -20,15 +37,52 @@ function doctorWhatsappLink(row) {
|
||||||
if (!digits) {
|
if (!digits) {
|
||||||
return { href: '', label: 'No WhatsApp number' };
|
return { href: '', label: 'No WhatsApp number' };
|
||||||
}
|
}
|
||||||
|
if (!row.tgics_whatsapp_validated) {
|
||||||
|
return { href: '', label: 'WhatsApp not validated' };
|
||||||
|
}
|
||||||
const name = String(row.name || '').trim();
|
const name = String(row.name || '').trim();
|
||||||
const link = `${SURVEY_ORIGIN}/${userId}/${contactId}`;
|
const link = `${SURVEY_ORIGIN}/${userId}/${contactId}`;
|
||||||
const text = `Dear Doctor${name ? ` ${name}` : ''},\n\nYou have been approved for TGICS (The Great Indian Cough Study).\nPlease open the link to add your surveys:\n${link}`;
|
const text = `Dear Doctor${name ? ` ${name}` : ''},\nThank you for participating in TGICS (The Great Indian Cough Study).\nPlease open the link and enter your WhatsApp mobile number to continue:\n${link}`;
|
||||||
return {
|
return {
|
||||||
href: `https://api.whatsapp.com/send?phone=${digits}&text=${encodeURIComponent(text)}`,
|
href: `https://api.whatsapp.com/send?phone=${digits}&text=${encodeURIComponent(text)}`,
|
||||||
label: '',
|
label: '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function emptyEditForm() {
|
||||||
|
return {
|
||||||
|
name: '',
|
||||||
|
wfsa_code: '',
|
||||||
|
phone: '',
|
||||||
|
email: '',
|
||||||
|
age: '',
|
||||||
|
years_of_practice: '',
|
||||||
|
average_no_of_patients: '',
|
||||||
|
state: '',
|
||||||
|
city: '',
|
||||||
|
qualification: '',
|
||||||
|
other_qualification: '',
|
||||||
|
type_of_practice: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowToForm(row) {
|
||||||
|
return {
|
||||||
|
name: row.name || '',
|
||||||
|
wfsa_code: row.wfsa_code || row.msl_code || '',
|
||||||
|
phone: String(row.phone || '').replace(/\D+/g, '').slice(-10),
|
||||||
|
email: row.email || '',
|
||||||
|
age: row.age || '',
|
||||||
|
years_of_practice: row.years_of_practice || '',
|
||||||
|
average_no_of_patients: row.average_no_of_patients || '',
|
||||||
|
state: row.state || '',
|
||||||
|
city: row.city || '',
|
||||||
|
qualification: row.qualification || '',
|
||||||
|
other_qualification: row.other_qualification || '',
|
||||||
|
type_of_practice: row.type_of_practice || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default function DoctorsSection({ onUnauthorized }) {
|
export default function DoctorsSection({ onUnauthorized }) {
|
||||||
const { data, loading, error, load } = useDashboardData(
|
const { data, loading, error, load } = useDashboardData(
|
||||||
fetchDashboardDoctors,
|
fetchDashboardDoctors,
|
||||||
|
|
@ -41,6 +95,11 @@ export default function DoctorsSection({ onUnauthorized }) {
|
||||||
const mrName = searchParams.get('mr_name') || '';
|
const mrName = searchParams.get('mr_name') || '';
|
||||||
const mrCode = searchParams.get('mr_code') || '';
|
const mrCode = searchParams.get('mr_code') || '';
|
||||||
|
|
||||||
|
const [editing, setEditing] = useState(null);
|
||||||
|
const [form, setForm] = useState(emptyEditForm);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saveError, setSaveError] = useState('');
|
||||||
|
|
||||||
const items = useMemo(() => {
|
const items = useMemo(() => {
|
||||||
const all = data?.items || [];
|
const all = data?.items || [];
|
||||||
return mrId ? all.filter((row) => row.user_id === mrId) : all;
|
return mrId ? all.filter((row) => row.user_id === mrId) : all;
|
||||||
|
|
@ -55,15 +114,89 @@ export default function DoctorsSection({ onUnauthorized }) {
|
||||||
const filtered = useMemo(
|
const filtered = useMemo(
|
||||||
() => items.filter((row) =>
|
() => items.filter((row) =>
|
||||||
matchesQuery(
|
matchesQuery(
|
||||||
[row.name, row.msl_code, row.phone, row.mr_name, row.employee_code, row.position_name],
|
[row.name, row.msl_code, row.wfsa_code, row.phone, row.mr_name, row.employee_code, row.position_name],
|
||||||
query
|
query
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
[items, query]
|
[items, query]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const cityOptions = useMemo(() => {
|
||||||
|
const list = citiesForState(form.state);
|
||||||
|
if (form.city && !list.includes(form.city)) {
|
||||||
|
return [form.city, ...list];
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}, [form.state, form.city]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editing) return undefined;
|
||||||
|
const onKey = (e) => {
|
||||||
|
if (e.key === 'Escape' && !saving) {
|
||||||
|
setEditing(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [editing, saving]);
|
||||||
|
|
||||||
const value = (n) => (loading ? '…' : n ?? 0);
|
const value = (n) => (loading ? '…' : n ?? 0);
|
||||||
|
|
||||||
|
const openEdit = (row) => {
|
||||||
|
setEditing(row);
|
||||||
|
setForm(rowToForm(row));
|
||||||
|
setSaveError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeEdit = () => {
|
||||||
|
if (saving) return;
|
||||||
|
setEditing(null);
|
||||||
|
setSaveError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const setField = (key, val) => {
|
||||||
|
setForm((prev) => {
|
||||||
|
const next = { ...prev, [key]: val };
|
||||||
|
if (key === 'state') {
|
||||||
|
next.city = '';
|
||||||
|
}
|
||||||
|
if (key === 'qualification' && val !== 'Others') {
|
||||||
|
next.other_qualification = '';
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setSaveError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!editing?.contact_id) return;
|
||||||
|
setSaving(true);
|
||||||
|
setSaveError('');
|
||||||
|
try {
|
||||||
|
await updateDashboardDoctor(editing.contact_id, {
|
||||||
|
name: form.name,
|
||||||
|
wfsa_code: form.wfsa_code,
|
||||||
|
phone: form.phone,
|
||||||
|
email: form.email,
|
||||||
|
age: form.age,
|
||||||
|
years_of_practice: form.years_of_practice,
|
||||||
|
average_no_of_patients: form.average_no_of_patients,
|
||||||
|
state: form.state,
|
||||||
|
city: form.city,
|
||||||
|
qualification: form.qualification,
|
||||||
|
other_qualification: form.other_qualification,
|
||||||
|
type_of_practice: form.type_of_practice,
|
||||||
|
});
|
||||||
|
setEditing(null);
|
||||||
|
await load(true);
|
||||||
|
} catch (err) {
|
||||||
|
setSaveError(err.message || 'Unable to save doctor.');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleExport = () => {
|
const handleExport = () => {
|
||||||
const name = mrId ? `tgics_doctors_${mrCode || mrId}` : 'tgics_doctors';
|
const name = mrId ? `tgics_doctors_${mrCode || mrId}` : 'tgics_doctors';
|
||||||
exportCsv(
|
exportCsv(
|
||||||
|
|
@ -72,7 +205,7 @@ export default function DoctorsSection({ onUnauthorized }) {
|
||||||
filtered.map((row) => [
|
filtered.map((row) => [
|
||||||
row.contact_id,
|
row.contact_id,
|
||||||
row.name,
|
row.name,
|
||||||
row.msl_code,
|
row.msl_code || row.wfsa_code,
|
||||||
row.phone,
|
row.phone,
|
||||||
row.mr_name,
|
row.mr_name,
|
||||||
row.employee_code,
|
row.employee_code,
|
||||||
|
|
@ -161,6 +294,7 @@ export default function DoctorsSection({ onUnauthorized }) {
|
||||||
<th>Position name</th>
|
<th>Position name</th>
|
||||||
<th>Total surveys</th>
|
<th>Total surveys</th>
|
||||||
<th>WhatsApp</th>
|
<th>WhatsApp</th>
|
||||||
|
<th>Edit</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
@ -170,7 +304,7 @@ export default function DoctorsSection({ onUnauthorized }) {
|
||||||
<tr key={row.contact_id}>
|
<tr key={row.contact_id}>
|
||||||
<td>{index + 1}</td>
|
<td>{index + 1}</td>
|
||||||
<td className="text-left">{row.name || '-'}</td>
|
<td className="text-left">{row.name || '-'}</td>
|
||||||
<td>{row.msl_code || '-'}</td>
|
<td>{row.msl_code || row.wfsa_code || '-'}</td>
|
||||||
<td>{row.phone || '-'}</td>
|
<td>{row.phone || '-'}</td>
|
||||||
<td className="text-left">{row.mr_name || '-'}</td>
|
<td className="text-left">{row.mr_name || '-'}</td>
|
||||||
<td>{row.employee_code || '-'}</td>
|
<td>{row.employee_code || '-'}</td>
|
||||||
|
|
@ -184,12 +318,21 @@ export default function DoctorsSection({ onUnauthorized }) {
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
Send WhatsApp
|
Send
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<span className="dash-muted">{whatsapp.label}</span>
|
<span className="dash-muted">{whatsapp.label}</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="dash-btn info dash-btn-link"
|
||||||
|
onClick={() => openEdit(row)}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
@ -198,6 +341,176 @@ export default function DoctorsSection({ onUnauthorized }) {
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{editing ? (
|
||||||
|
<div className="dash-modal-backdrop" role="presentation" onClick={closeEdit}>
|
||||||
|
<div
|
||||||
|
className="dash-modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="doctor-edit-title"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="dash-modal-header">
|
||||||
|
<h2 id="doctor-edit-title">Edit doctor</h2>
|
||||||
|
<button type="button" className="dash-btn dark" onClick={closeEdit} disabled={saving}>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form className="dash-edit-form" onSubmit={handleSave}>
|
||||||
|
<label>
|
||||||
|
Name of the Practitioner
|
||||||
|
<input
|
||||||
|
className="dash-input"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setField('name', e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
WFSA Code
|
||||||
|
<input
|
||||||
|
className="dash-input"
|
||||||
|
value={form.wfsa_code}
|
||||||
|
onChange={(e) => setField('wfsa_code', e.target.value.replace(/\D+/g, '').slice(0, 10))}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
WhatsApp Mobile Number
|
||||||
|
<input
|
||||||
|
className="dash-input"
|
||||||
|
value={form.phone}
|
||||||
|
onChange={(e) => setField('phone', e.target.value.replace(/\D+/g, '').slice(0, 10))}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Email ID
|
||||||
|
<input
|
||||||
|
className="dash-input"
|
||||||
|
type="email"
|
||||||
|
value={form.email}
|
||||||
|
onChange={(e) => setField('email', e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Age
|
||||||
|
<input
|
||||||
|
className="dash-input"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={form.age}
|
||||||
|
onChange={(e) => setField('age', e.target.value.replace(/\D+/g, '').slice(0, 3))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Years of Practice
|
||||||
|
<input
|
||||||
|
className="dash-input"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={form.years_of_practice}
|
||||||
|
onChange={(e) => setField('years_of_practice', e.target.value.replace(/\D+/g, '').slice(0, 3))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Average No. of Patients
|
||||||
|
<input
|
||||||
|
className="dash-input"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={form.average_no_of_patients}
|
||||||
|
onChange={(e) => setField('average_no_of_patients', e.target.value.replace(/\D+/g, '').slice(0, 6))}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
State
|
||||||
|
<select
|
||||||
|
className="dash-input"
|
||||||
|
value={form.state}
|
||||||
|
onChange={(e) => setField('state', e.target.value)}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value=""> -- Select State -- </option>
|
||||||
|
{INDIA_STATES.map((s) => (
|
||||||
|
<option key={s} value={s}>{s}</option>
|
||||||
|
))}
|
||||||
|
{form.state && !INDIA_STATES.includes(form.state) ? (
|
||||||
|
<option value={form.state}>{form.state} (current)</option>
|
||||||
|
) : null}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
City / Town
|
||||||
|
<select
|
||||||
|
className="dash-input"
|
||||||
|
value={form.city}
|
||||||
|
onChange={(e) => setField('city', e.target.value)}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value=""> -- Select City -- </option>
|
||||||
|
{cityOptions.map((c) => (
|
||||||
|
<option key={c} value={c}>{c}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Qualification
|
||||||
|
<select
|
||||||
|
className="dash-input"
|
||||||
|
value={form.qualification}
|
||||||
|
onChange={(e) => setField('qualification', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value=""> -- Select -- </option>
|
||||||
|
{QUALIFICATIONS.map((q) => (
|
||||||
|
<option key={q} value={q}>{q}</option>
|
||||||
|
))}
|
||||||
|
{form.qualification && !QUALIFICATIONS.includes(form.qualification) ? (
|
||||||
|
<option value={form.qualification}>{form.qualification}</option>
|
||||||
|
) : null}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{form.qualification === 'Others' ? (
|
||||||
|
<label>
|
||||||
|
Other Qualification
|
||||||
|
<input
|
||||||
|
className="dash-input"
|
||||||
|
value={form.other_qualification}
|
||||||
|
onChange={(e) => setField('other_qualification', e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
<label>
|
||||||
|
Type of Practice
|
||||||
|
<select
|
||||||
|
className="dash-input"
|
||||||
|
value={form.type_of_practice}
|
||||||
|
onChange={(e) => setField('type_of_practice', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value=""> -- Select -- </option>
|
||||||
|
{PRACTICE_TYPES.map((t) => (
|
||||||
|
<option key={t} value={t}>{t}</option>
|
||||||
|
))}
|
||||||
|
{form.type_of_practice && !PRACTICE_TYPES.includes(form.type_of_practice) ? (
|
||||||
|
<option value={form.type_of_practice}>{form.type_of_practice}</option>
|
||||||
|
) : null}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{saveError ? <p className="error">{saveError}</p> : null}
|
||||||
|
|
||||||
|
<div className="dash-modal-actions">
|
||||||
|
<button type="button" className="dash-btn dark" onClick={closeEdit} disabled={saving}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="dash-btn info" disabled={saving}>
|
||||||
|
{saving ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,59 @@ function answerText(question, answers) {
|
||||||
return (Array.isArray(raw) ? raw : [raw]).map(labelOf).join(', ');
|
return (Array.isArray(raw) ? raw : [raw]).map(labelOf).join(', ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function optionSelected(question, answers, optionValue) {
|
||||||
|
const raw = answers?.[question.code];
|
||||||
|
if (Array.isArray(raw)) return raw.includes(optionValue);
|
||||||
|
return raw === optionValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionCell(question, answers, optionValue) {
|
||||||
|
return optionSelected(question, answers, optionValue) ? 'Yes' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared column model for table + CSV: combined + per-option for multi. */
|
||||||
|
function buildExportColumns() {
|
||||||
|
const columns = [];
|
||||||
|
for (const code of QUESTION_CODES) {
|
||||||
|
const question = QUESTIONS[code];
|
||||||
|
if (!question) continue;
|
||||||
|
if (question.type === 'multi' && Array.isArray(question.options)) {
|
||||||
|
columns.push({
|
||||||
|
kind: 'combined',
|
||||||
|
code,
|
||||||
|
question,
|
||||||
|
header: question.label,
|
||||||
|
});
|
||||||
|
for (const opt of question.options) {
|
||||||
|
columns.push({
|
||||||
|
kind: 'option',
|
||||||
|
code,
|
||||||
|
question,
|
||||||
|
option: opt,
|
||||||
|
header: `${question.label} - ${opt.label}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
columns.push({
|
||||||
|
kind: 'single',
|
||||||
|
code,
|
||||||
|
question,
|
||||||
|
header: question.label,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return columns;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cellForColumn(col, answers) {
|
||||||
|
if (col.kind === 'option') {
|
||||||
|
return optionCell(col.question, answers, col.option.value);
|
||||||
|
}
|
||||||
|
return answerText(col.question, answers);
|
||||||
|
}
|
||||||
|
|
||||||
|
const EXPORT_COLUMNS = buildExportColumns();
|
||||||
|
|
||||||
export default function PatientsSection({ onUnauthorized }) {
|
export default function PatientsSection({ onUnauthorized }) {
|
||||||
const { data, loading, error, load } = useDashboardData(
|
const { data, loading, error, load } = useDashboardData(
|
||||||
fetchDashboardPatients,
|
fetchDashboardPatients,
|
||||||
|
|
@ -62,7 +115,7 @@ export default function PatientsSection({ onUnauthorized }) {
|
||||||
'MR name',
|
'MR name',
|
||||||
'Employee code',
|
'Employee code',
|
||||||
'Position name',
|
'Position name',
|
||||||
...QUESTION_CODES.map((code) => QUESTIONS[code].label),
|
...EXPORT_COLUMNS.map((col) => col.header),
|
||||||
'Submitted on',
|
'Submitted on',
|
||||||
],
|
],
|
||||||
filtered.map((row) => [
|
filtered.map((row) => [
|
||||||
|
|
@ -71,7 +124,7 @@ export default function PatientsSection({ onUnauthorized }) {
|
||||||
row.mr_name,
|
row.mr_name,
|
||||||
row.employee_code,
|
row.employee_code,
|
||||||
row.position_name,
|
row.position_name,
|
||||||
...QUESTION_CODES.map((code) => answerText(QUESTIONS[code], row.answers)),
|
...EXPORT_COLUMNS.map((col) => cellForColumn(col, row.answers)),
|
||||||
formatDateTime(row.created_at),
|
formatDateTime(row.created_at),
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
|
|
@ -133,15 +186,44 @@ export default function PatientsSection({ onUnauthorized }) {
|
||||||
<table className="dash-table dash-table-wide">
|
<table className="dash-table dash-table-wide">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>No</th>
|
<th rowSpan={2}>No</th>
|
||||||
<th className="text-left">Doctor name</th>
|
<th className="text-left" rowSpan={2}>Doctor name</th>
|
||||||
<th className="text-left">MR name</th>
|
<th className="text-left" rowSpan={2}>MR name</th>
|
||||||
<th>Employee code</th>
|
<th rowSpan={2}>Employee code</th>
|
||||||
<th>Position name</th>
|
<th rowSpan={2}>Position name</th>
|
||||||
{QUESTION_CODES.map((code) => (
|
{QUESTION_CODES.map((code) => {
|
||||||
<th key={code} className="dash-question">{QUESTIONS[code].label}</th>
|
const question = QUESTIONS[code];
|
||||||
))}
|
if (question?.type === 'multi' && question.options?.length) {
|
||||||
<th>Submitted on</th>
|
return (
|
||||||
|
<th
|
||||||
|
key={code}
|
||||||
|
className="dash-question dash-group"
|
||||||
|
colSpan={question.options.length}
|
||||||
|
>
|
||||||
|
{question.label}
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<th key={code} className="dash-question" rowSpan={2}>
|
||||||
|
{question.label}
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<th rowSpan={2}>Submitted on</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
{QUESTION_CODES.flatMap((code) => {
|
||||||
|
const question = QUESTIONS[code];
|
||||||
|
if (question?.type !== 'multi' || !question.options?.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return question.options.map((opt) => (
|
||||||
|
<th key={`${code}:${opt.value}`} className="dash-option">
|
||||||
|
{opt.label}
|
||||||
|
</th>
|
||||||
|
));
|
||||||
|
})}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
@ -152,11 +234,21 @@ export default function PatientsSection({ onUnauthorized }) {
|
||||||
<td className="text-left">{row.mr_name || '-'}</td>
|
<td className="text-left">{row.mr_name || '-'}</td>
|
||||||
<td>{row.employee_code || '-'}</td>
|
<td>{row.employee_code || '-'}</td>
|
||||||
<td>{row.position_name || '-'}</td>
|
<td>{row.position_name || '-'}</td>
|
||||||
{QUESTION_CODES.map((code) => (
|
{QUESTION_CODES.flatMap((code) => {
|
||||||
<td key={code} className="text-left dash-answer">
|
const question = QUESTIONS[code];
|
||||||
{answerText(QUESTIONS[code], row.answers)}
|
if (question?.type === 'multi' && question.options?.length) {
|
||||||
</td>
|
return question.options.map((opt) => (
|
||||||
))}
|
<td key={`${row.id}:${code}:${opt.value}`} className="text-left dash-answer">
|
||||||
|
{optionCell(question, row.answers, opt.value) || '-'}
|
||||||
|
</td>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
<td key={`${row.id}:${code}`} className="text-left dash-answer">
|
||||||
|
{answerText(question, row.answers)}
|
||||||
|
</td>,
|
||||||
|
];
|
||||||
|
})}
|
||||||
<td>{formatDateTime(row.created_at)}</td>
|
<td>{formatDateTime(row.created_at)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,7 @@ export default function QuestionField({ question, value, otherValue, onChange, o
|
||||||
<legend className="field-label">
|
<legend className="field-label">
|
||||||
{question.label} <span className="req">*</span>
|
{question.label} <span className="req">*</span>
|
||||||
</legend>
|
</legend>
|
||||||
|
<p className="field-hint">Multiple answers can be selected</p>
|
||||||
<div className="option-list">
|
<div className="option-list">
|
||||||
{question.options.map((opt) => {
|
{question.options.map((opt) => {
|
||||||
const checked = selected.includes(opt.value);
|
const checked = selected.includes(opt.value);
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,30 @@ import { fetchTmUsers } from '../api';
|
||||||
import DashboardLoading from './DashboardLoading';
|
import DashboardLoading from './DashboardLoading';
|
||||||
import exportCsv from './exportCsv';
|
import exportCsv from './exportCsv';
|
||||||
|
|
||||||
|
const SURVEY_ORIGIN = 'https://tgics.cttr.in';
|
||||||
const EMPTY_TOTALS = { tms: 0, contacts_created: 0, doctors_with_surveys: 0, total_surveys: 0 };
|
const EMPTY_TOTALS = { tms: 0, contacts_created: 0, doctors_with_surveys: 0, total_surveys: 0 };
|
||||||
|
|
||||||
|
function mrWhatsappLink(row) {
|
||||||
|
const userId = String(row.user_id || '').trim();
|
||||||
|
if (!userId) {
|
||||||
|
return { href: '', label: 'Link unavailable' };
|
||||||
|
}
|
||||||
|
let digits = String(row.phone || '').replace(/\D+/g, '');
|
||||||
|
if (digits.length === 10) {
|
||||||
|
digits = `91${digits}`;
|
||||||
|
}
|
||||||
|
if (!digits) {
|
||||||
|
return { href: '', label: 'No WhatsApp number' };
|
||||||
|
}
|
||||||
|
const name = String(row.name || '').trim();
|
||||||
|
const link = `${SURVEY_ORIGIN}/${userId}`;
|
||||||
|
const text = `Dear${name ? ` ${name}` : ''},\n\nPlease share this TGICS link with your enrolled doctors. They should open it and enter their WhatsApp mobile number to continue:\n${link}`;
|
||||||
|
return {
|
||||||
|
href: `https://api.whatsapp.com/send?phone=${digits}&text=${encodeURIComponent(text)}`,
|
||||||
|
label: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default function TmUsersSection({ onUnauthorized }) {
|
export default function TmUsersSection({ onUnauthorized }) {
|
||||||
const [items, setItems] = useState([]);
|
const [items, setItems] = useState([]);
|
||||||
const [totals, setTotals] = useState(EMPTY_TOTALS);
|
const [totals, setTotals] = useState(EMPTY_TOTALS);
|
||||||
|
|
@ -47,7 +69,7 @@ export default function TmUsersSection({ onUnauthorized }) {
|
||||||
const handleExport = () => {
|
const handleExport = () => {
|
||||||
exportCsv(
|
exportCsv(
|
||||||
'tgics_mr_dashboard',
|
'tgics_mr_dashboard',
|
||||||
['MR ID', 'Name', 'Employee code', 'Position name', 'Contacts created', 'Doctors with surveys', 'Total surveys'],
|
['MR ID', 'Name', 'Employee code', 'Position name', 'Doctors Enrolled', 'Active Doctors', 'Total surveys'],
|
||||||
filtered.map((row) => [
|
filtered.map((row) => [
|
||||||
row.user_id,
|
row.user_id,
|
||||||
row.name,
|
row.name,
|
||||||
|
|
@ -67,12 +89,12 @@ export default function TmUsersSection({ onUnauthorized }) {
|
||||||
<small>Total MR Count</small>
|
<small>Total MR Count</small>
|
||||||
<strong>{loading ? '…' : totals.tms}</strong>
|
<strong>{loading ? '…' : totals.tms}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="dash-stat info">
|
<div className="dash-stat info emphasized">
|
||||||
<small>Total Contacts Created</small>
|
<small>Total Doctors Enrolled</small>
|
||||||
<strong>{loading ? '…' : totals.contacts_created}</strong>
|
<strong>{loading ? '…' : totals.contacts_created}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="dash-stat warning">
|
<div className="dash-stat warning emphasized">
|
||||||
<small>Total Doctors with Surveys</small>
|
<small>Active Doctors</small>
|
||||||
<strong>{loading ? '…' : totals.doctors_with_surveys}</strong>
|
<strong>{loading ? '…' : totals.doctors_with_surveys}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="dash-stat success">
|
<div className="dash-stat success">
|
||||||
|
|
@ -129,21 +151,24 @@ export default function TmUsersSection({ onUnauthorized }) {
|
||||||
<th className="text-left">Name</th>
|
<th className="text-left">Name</th>
|
||||||
<th>Employee code</th>
|
<th>Employee code</th>
|
||||||
<th>Position name</th>
|
<th>Position name</th>
|
||||||
<th>Contacts created</th>
|
<th className="dash-col-emphasis">Doctors Enrolled</th>
|
||||||
<th>Doctors with surveys</th>
|
<th className="dash-col-emphasis">Active Doctors</th>
|
||||||
<th>Total surveys</th>
|
<th>Total surveys</th>
|
||||||
<th>Doctors</th>
|
<th>Doctors</th>
|
||||||
|
<th>WhatsApp</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filtered.map((row, index) => (
|
{filtered.map((row, index) => {
|
||||||
|
const whatsapp = mrWhatsappLink(row);
|
||||||
|
return (
|
||||||
<tr key={row.user_id}>
|
<tr key={row.user_id}>
|
||||||
<td>{index + 1}</td>
|
<td>{index + 1}</td>
|
||||||
<td className="text-left">{row.name || '-'}</td>
|
<td className="text-left">{row.name || '-'}</td>
|
||||||
<td>{row.employee_code || '-'}</td>
|
<td>{row.employee_code || '-'}</td>
|
||||||
<td>{row.position_name || '-'}</td>
|
<td>{row.position_name || '-'}</td>
|
||||||
<td className="dash-num">{row.contacts_created}</td>
|
<td className="dash-num dash-col-emphasis">{row.contacts_created}</td>
|
||||||
<td className="dash-num">{row.doctors_with_surveys}</td>
|
<td className="dash-num dash-col-emphasis">{row.doctors_with_surveys}</td>
|
||||||
<td className="dash-num">{row.total_surveys}</td>
|
<td className="dash-num">{row.total_surveys}</td>
|
||||||
<td>
|
<td>
|
||||||
{row.contacts_created > 0 ? (
|
{row.contacts_created > 0 ? (
|
||||||
|
|
@ -163,8 +188,23 @@ export default function TmUsersSection({ onUnauthorized }) {
|
||||||
<span className="dash-muted">-</span>
|
<span className="dash-muted">-</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
{whatsapp.href ? (
|
||||||
|
<a
|
||||||
|
className="dash-btn whatsapp dash-btn-link"
|
||||||
|
href={whatsapp.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
Send WhatsApp
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="dash-muted">{whatsapp.label}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -364,3 +364,83 @@
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dash-stat.emphasized {
|
||||||
|
outline: 2px solid var(--dash-heading);
|
||||||
|
outline-offset: 0;
|
||||||
|
box-shadow: var(--dash-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-stat.emphasized small,
|
||||||
|
.dash-stat.emphasized strong {
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--dash-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-col-emphasis {
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
background: rgba(15, 23, 42, 0.45);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-modal {
|
||||||
|
width: min(640px, 100%);
|
||||||
|
background: var(--dash-card);
|
||||||
|
border-radius: var(--dash-radius);
|
||||||
|
box-shadow: var(--dash-shadow);
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-modal-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-edit-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-edit-form label {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--dash-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-table th.dash-group {
|
||||||
|
text-align: center;
|
||||||
|
background: rgba(67, 191, 229, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-table th.dash-option {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,13 @@
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { fetchDoctor, fetchPatients, loginDoctorByPhone, submitSurvey } from '../api';
|
import {
|
||||||
|
fetchDoctor,
|
||||||
|
fetchPatients,
|
||||||
|
fetchSurveyDraft,
|
||||||
|
loginDoctorByPhone,
|
||||||
|
saveSurveyDraft,
|
||||||
|
submitSurvey,
|
||||||
|
} from '../api';
|
||||||
import QuestionField from '../components/QuestionField';
|
import QuestionField from '../components/QuestionField';
|
||||||
import {
|
import {
|
||||||
QUESTIONS,
|
QUESTIONS,
|
||||||
|
|
@ -39,6 +46,18 @@ function formatSubmittedAt(value) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mergeDraftAnswers(draft) {
|
||||||
|
const base = emptyAnswers();
|
||||||
|
if (!draft || typeof draft !== 'object') return base;
|
||||||
|
const next = { ...base };
|
||||||
|
for (const key of Object.keys(base)) {
|
||||||
|
if (draft[key] !== undefined) {
|
||||||
|
next[key] = draft[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
export default function SurveyPage() {
|
export default function SurveyPage() {
|
||||||
const { userId, contactId } = useParams();
|
const { userId, contactId } = useParams();
|
||||||
const [loading, setLoading] = useState(Boolean(contactId));
|
const [loading, setLoading] = useState(Boolean(contactId));
|
||||||
|
|
@ -57,6 +76,11 @@ export default function SurveyPage() {
|
||||||
const [answers, setAnswers] = useState(emptyAnswers);
|
const [answers, setAnswers] = useState(emptyAnswers);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [formError, setFormError] = useState('');
|
const [formError, setFormError] = useState('');
|
||||||
|
const [draftNotice, setDraftNotice] = useState('');
|
||||||
|
const [draftSaving, setDraftSaving] = useState(false);
|
||||||
|
const [draftStatus, setDraftStatus] = useState('');
|
||||||
|
const autosaveTimer = useRef(null);
|
||||||
|
const skipAutosave = useRef(false);
|
||||||
|
|
||||||
const loadPatients = useCallback(async (doctorContactId) => {
|
const loadPatients = useCallback(async (doctorContactId) => {
|
||||||
if (!doctorContactId) {
|
if (!doctorContactId) {
|
||||||
|
|
@ -110,6 +134,47 @@ export default function SurveyPage() {
|
||||||
}
|
}
|
||||||
}, [doctor?.contact_id, loadPatients]);
|
}, [doctor?.contact_id, loadPatients]);
|
||||||
|
|
||||||
|
const persistDraft = useCallback(async (answersPayload, { silent } = {}) => {
|
||||||
|
if (!doctor?.contact_id || !userId) return;
|
||||||
|
if (!silent) setDraftSaving(true);
|
||||||
|
try {
|
||||||
|
await saveSurveyDraft({
|
||||||
|
user_id: userId,
|
||||||
|
contact_id: doctor.contact_id,
|
||||||
|
answers: answersPayload,
|
||||||
|
});
|
||||||
|
if (!silent) {
|
||||||
|
setDraftStatus('Draft saved.');
|
||||||
|
setTimeout(() => setDraftStatus(''), 2000);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (!silent) {
|
||||||
|
setFormError(err.message || 'Unable to save draft.');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!silent) setDraftSaving(false);
|
||||||
|
}
|
||||||
|
}, [doctor?.contact_id, userId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (view !== 'form' || !doctor?.contact_id) return undefined;
|
||||||
|
if (skipAutosave.current) {
|
||||||
|
skipAutosave.current = false;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (autosaveTimer.current) {
|
||||||
|
clearTimeout(autosaveTimer.current);
|
||||||
|
}
|
||||||
|
autosaveTimer.current = setTimeout(() => {
|
||||||
|
persistDraft(answers, { silent: true });
|
||||||
|
}, 1500);
|
||||||
|
return () => {
|
||||||
|
if (autosaveTimer.current) {
|
||||||
|
clearTimeout(autosaveTimer.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [answers, view, doctor?.contact_id, persistDraft]);
|
||||||
|
|
||||||
const handlePhoneLogin = async (event) => {
|
const handlePhoneLogin = async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setLoginError('');
|
setLoginError('');
|
||||||
|
|
@ -125,17 +190,36 @@ export default function SurveyPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const openAddPatient = () => {
|
const openAddPatient = async () => {
|
||||||
|
skipAutosave.current = true;
|
||||||
setAnswers(emptyAnswers());
|
setAnswers(emptyAnswers());
|
||||||
setStepIndex(0);
|
setStepIndex(0);
|
||||||
setFormError('');
|
setFormError('');
|
||||||
|
setDraftNotice('');
|
||||||
|
setDraftStatus('');
|
||||||
setView('form');
|
setView('form');
|
||||||
|
|
||||||
|
if (!doctor?.contact_id) return;
|
||||||
|
try {
|
||||||
|
const res = await fetchSurveyDraft(doctor.contact_id, userId);
|
||||||
|
const draft = res.data?.draft;
|
||||||
|
if (draft?.answers) {
|
||||||
|
skipAutosave.current = true;
|
||||||
|
setAnswers(mergeDraftAnswers(draft.answers));
|
||||||
|
setDraftNotice('Draft restored');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore draft load failures; doctor can still fill a fresh form
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const backToList = () => {
|
const backToList = () => {
|
||||||
|
skipAutosave.current = true;
|
||||||
setAnswers(emptyAnswers());
|
setAnswers(emptyAnswers());
|
||||||
setStepIndex(0);
|
setStepIndex(0);
|
||||||
setFormError('');
|
setFormError('');
|
||||||
|
setDraftNotice('');
|
||||||
|
setDraftStatus('');
|
||||||
setView('list');
|
setView('list');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -180,6 +264,11 @@ export default function SurveyPage() {
|
||||||
setStepIndex((i) => Math.max(0, i - 1));
|
setStepIndex((i) => Math.max(0, i - 1));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSaveDraft = async () => {
|
||||||
|
setFormError('');
|
||||||
|
await persistDraft(answers, { silent: false });
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
const err = validateStep(currentStep.id, answers);
|
const err = validateStep(currentStep.id, answers);
|
||||||
if (err) {
|
if (err) {
|
||||||
|
|
@ -198,8 +287,11 @@ export default function SurveyPage() {
|
||||||
contact_id: doctor.contact_id,
|
contact_id: doctor.contact_id,
|
||||||
answers,
|
answers,
|
||||||
});
|
});
|
||||||
|
skipAutosave.current = true;
|
||||||
setAnswers(emptyAnswers());
|
setAnswers(emptyAnswers());
|
||||||
setStepIndex(0);
|
setStepIndex(0);
|
||||||
|
setDraftNotice('');
|
||||||
|
setDraftStatus('');
|
||||||
setView('list');
|
setView('list');
|
||||||
await loadPatients(doctor.contact_id);
|
await loadPatients(doctor.contact_id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -219,6 +311,8 @@ export default function SurveyPage() {
|
||||||
setError('');
|
setError('');
|
||||||
setPatients([]);
|
setPatients([]);
|
||||||
setPatientsError('');
|
setPatientsError('');
|
||||||
|
setDraftNotice('');
|
||||||
|
setDraftStatus('');
|
||||||
setView('list');
|
setView('list');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -362,6 +456,9 @@ export default function SurveyPage() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{draftNotice ? <p className="muted small">{draftNotice}</p> : null}
|
||||||
|
{draftStatus ? <p className="muted small">{draftStatus}</p> : null}
|
||||||
|
|
||||||
<div className="progress-wrap">
|
<div className="progress-wrap">
|
||||||
<div className="progress-bar" style={{ width: `${progress}%` }} />
|
<div className="progress-bar" style={{ width: `${progress}%` }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -386,10 +483,18 @@ export default function SurveyPage() {
|
||||||
{formError ? <p className="error">{formError}</p> : null}
|
{formError ? <p className="error">{formError}</p> : null}
|
||||||
|
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
<button type="button" className="btn ghost" onClick={goBack} disabled={submitting}>
|
<button type="button" className="btn ghost" onClick={goBack} disabled={submitting || draftSaving}>
|
||||||
{stepIndex > 0 ? 'Back' : 'Back to list'}
|
{stepIndex > 0 ? 'Back' : 'Back to list'}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="btn primary" onClick={goNext} disabled={submitting}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn ghost"
|
||||||
|
onClick={handleSaveDraft}
|
||||||
|
disabled={submitting || draftSaving}
|
||||||
|
>
|
||||||
|
{draftSaving ? 'Saving…' : 'Save Draft'}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn primary" onClick={goNext} disabled={submitting || draftSaving}>
|
||||||
{submitting
|
{submitting
|
||||||
? 'Submitting…'
|
? 'Submitting…'
|
||||||
: currentStep.id === 'cough' && answers.has_cough === 'no'
|
: currentStep.id === 'cough' && answers.has_cough === 'no'
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue