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
|
||||
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)
|
||||
) 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 {
|
||||
connection.release();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,14 @@
|
|||
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() {
|
||||
return String(process.env.TGICS_TM_ROLE_IDS || '')
|
||||
|
|
@ -98,6 +107,7 @@ async function getTmUsers(request, h) {
|
|||
user_id: user.id,
|
||||
name: `${user.firstname || ''} ${user.lastname || ''}`.trim(),
|
||||
employee_code: user.employee_code || '',
|
||||
phone: user.phone || position.phone || '',
|
||||
designation: position.designation || user.designation || '',
|
||||
position_name: position.position_name || '',
|
||||
position_code: position.position_code || '',
|
||||
|
|
@ -171,13 +181,10 @@ async function getDoctors(request, h) {
|
|||
|
||||
const items = contacts.map((contact) => {
|
||||
const surveys = surveysByContact.get(contact.id);
|
||||
const doctor = toDoctorPayload(contact, contact.user_id);
|
||||
return {
|
||||
contact_id: contact.id,
|
||||
name: contact.name || '',
|
||||
msl_code: contact.msl_code || '',
|
||||
phone: contact.phone || '',
|
||||
email: contact.email || '',
|
||||
user_id: contact.user_id || '',
|
||||
...doctor,
|
||||
msl_code: contact.msl_code || doctor.wfsa_code || '',
|
||||
...mrDetails(contact.user_id, positions, contact),
|
||||
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({
|
||||
status: 'success',
|
||||
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 authed = (path, handler) => ({
|
||||
method: 'GET',
|
||||
const authed = (method, path, handler) => ({
|
||||
method,
|
||||
path,
|
||||
options: {
|
||||
pre: [{ method: requireAuth }],
|
||||
|
|
@ -11,9 +18,10 @@ const authed = (path, handler) => ({
|
|||
});
|
||||
|
||||
module.exports = [
|
||||
authed('/api/dashboard/stats', surveyStats),
|
||||
authed('/api/dashboard/surveys', listSurveys),
|
||||
authed('/api/dashboard/tm-users', getTmUsers),
|
||||
authed('/api/dashboard/doctors', getDoctors),
|
||||
authed('/api/dashboard/patients', getPatients),
|
||||
authed('GET', '/api/dashboard/stats', surveyStats),
|
||||
authed('GET', '/api/dashboard/surveys', listSurveys),
|
||||
authed('GET', '/api/dashboard/tm-users', getTmUsers),
|
||||
authed('GET', '/api/dashboard/doctors', getDoctors),
|
||||
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 = [
|
||||
{
|
||||
|
|
@ -17,6 +25,30 @@ module.exports = [
|
|||
},
|
||||
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',
|
||||
path: '/api/surveys/counts',
|
||||
|
|
|
|||
|
|
@ -1,32 +1,35 @@
|
|||
const bcrypt = require('bcrypt');
|
||||
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() {
|
||||
const email = 'ravi@stratawiz.com';
|
||||
const password = 'qwerty';
|
||||
const name = 'Ravi';
|
||||
const role = 'admin';
|
||||
for (const user of DASHBOARD_USERS) {
|
||||
const email = user.email.trim().toLowerCase();
|
||||
const [existing] = await pool.execute(
|
||||
'SELECT id FROM users WHERE email = ? LIMIT 1',
|
||||
[email]
|
||||
);
|
||||
|
||||
const [existing] = await pool.execute(
|
||||
'SELECT id FROM users WHERE email = ? LIMIT 1',
|
||||
[email]
|
||||
);
|
||||
if (existing.length) {
|
||||
console.log('Seed user already exists:', email);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existing.length) {
|
||||
console.log('Seed user already exists:', email);
|
||||
return;
|
||||
const passwordHash = await bcrypt.hash(user.password, 10);
|
||||
await pool.execute(
|
||||
`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 };
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ const init = async () => {
|
|||
...require('./routes/dashboardRoutes'),
|
||||
...require('./routes/doctorRoutes'),
|
||||
...require('./routes/surveyRoutes'),
|
||||
...require('./routes/whatsappRoutes'),
|
||||
]);
|
||||
|
||||
await initializeDb();
|
||||
|
|
|
|||
|
|
@ -54,10 +54,16 @@ function isTgicsEnrolled(contact) {
|
|||
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) {
|
||||
return {
|
||||
contact_id: contact.id,
|
||||
user_id: userId || null,
|
||||
user_id: userId || contact.user_id || null,
|
||||
name: contact.name || '',
|
||||
wfsa_code: contact.msl_code || '',
|
||||
phone: contact.phone || '',
|
||||
|
|
@ -65,13 +71,50 @@ function toDoctorPayload(contact, userId) {
|
|||
age: contact.age || '',
|
||||
years_of_practice: contact.years_of_practice || '',
|
||||
type_of_practice: contact.type_of_practice || '',
|
||||
average_no_of_patients: contact.average_no_of_patients || '',
|
||||
city: contact.city || contact.city_town || '',
|
||||
state: contact.state || '',
|
||||
qualification: contact.qualification || '',
|
||||
other_qualification: contact.other_qualification || '',
|
||||
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) {
|
||||
const contact = await getContactById(contactId);
|
||||
if (!contact) {
|
||||
|
|
@ -255,6 +298,7 @@ async function getPositionsByOwner({ refresh = false } = {}) {
|
|||
designation: row.designation || '',
|
||||
name: `${info.firstname || ''} ${info.lastname || ''}`.trim(),
|
||||
employee_code: info.employee_code || '',
|
||||
phone: info.phone || '',
|
||||
});
|
||||
}
|
||||
return byOwner;
|
||||
|
|
@ -300,7 +344,9 @@ module.exports = {
|
|||
getPositionsByOwner,
|
||||
getEnrolledContacts,
|
||||
getContactById,
|
||||
updateContact,
|
||||
isTgicsEnrolled,
|
||||
isTgicsWhatsappValidated,
|
||||
toDoctorPayload,
|
||||
resolveTgicsDoctor,
|
||||
resolveTgicsDoctorByPhone,
|
||||
|
|
|
|||
|
|
@ -198,6 +198,12 @@
|
|||
padding: 0;
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
|
|
|
|||
|
|
@ -90,6 +90,38 @@ export function fetchDashboardDoctors(refresh = false) {
|
|||
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) {
|
||||
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 { fetchDashboardDoctors } from '../api';
|
||||
import { fetchDashboardDoctors, updateDashboardDoctor } from '../api';
|
||||
import { INDIA_STATES, citiesForState } from '../indiaGeo';
|
||||
import DashboardLoading from './DashboardLoading';
|
||||
import exportCsv from './exportCsv';
|
||||
import useDashboardData, { matchesQuery } from './useDashboardData';
|
||||
|
||||
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) {
|
||||
const userId = String(row.user_id || '').trim();
|
||||
const contactId = String(row.contact_id || '').trim();
|
||||
|
|
@ -20,15 +37,52 @@ function doctorWhatsappLink(row) {
|
|||
if (!digits) {
|
||||
return { href: '', label: 'No WhatsApp number' };
|
||||
}
|
||||
if (!row.tgics_whatsapp_validated) {
|
||||
return { href: '', label: 'WhatsApp not validated' };
|
||||
}
|
||||
const name = String(row.name || '').trim();
|
||||
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 {
|
||||
href: `https://api.whatsapp.com/send?phone=${digits}&text=${encodeURIComponent(text)}`,
|
||||
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 }) {
|
||||
const { data, loading, error, load } = useDashboardData(
|
||||
fetchDashboardDoctors,
|
||||
|
|
@ -41,6 +95,11 @@ export default function DoctorsSection({ onUnauthorized }) {
|
|||
const mrName = searchParams.get('mr_name') || '';
|
||||
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 all = data?.items || [];
|
||||
return mrId ? all.filter((row) => row.user_id === mrId) : all;
|
||||
|
|
@ -55,15 +114,89 @@ export default function DoctorsSection({ onUnauthorized }) {
|
|||
const filtered = useMemo(
|
||||
() => items.filter((row) =>
|
||||
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
|
||||
)
|
||||
),
|
||||
[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 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 name = mrId ? `tgics_doctors_${mrCode || mrId}` : 'tgics_doctors';
|
||||
exportCsv(
|
||||
|
|
@ -72,7 +205,7 @@ export default function DoctorsSection({ onUnauthorized }) {
|
|||
filtered.map((row) => [
|
||||
row.contact_id,
|
||||
row.name,
|
||||
row.msl_code,
|
||||
row.msl_code || row.wfsa_code,
|
||||
row.phone,
|
||||
row.mr_name,
|
||||
row.employee_code,
|
||||
|
|
@ -161,6 +294,7 @@ export default function DoctorsSection({ onUnauthorized }) {
|
|||
<th>Position name</th>
|
||||
<th>Total surveys</th>
|
||||
<th>WhatsApp</th>
|
||||
<th>Edit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -170,7 +304,7 @@ export default function DoctorsSection({ onUnauthorized }) {
|
|||
<tr key={row.contact_id}>
|
||||
<td>{index + 1}</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 className="text-left">{row.mr_name || '-'}</td>
|
||||
<td>{row.employee_code || '-'}</td>
|
||||
|
|
@ -184,12 +318,21 @@ export default function DoctorsSection({ onUnauthorized }) {
|
|||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Send WhatsApp
|
||||
Send
|
||||
</a>
|
||||
) : (
|
||||
<span className="dash-muted">{whatsapp.label}</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="dash-btn info dash-btn-link"
|
||||
onClick={() => openEdit(row)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
|
|
@ -198,6 +341,176 @@ export default function DoctorsSection({ onUnauthorized }) {
|
|||
</div>
|
||||
) : null}
|
||||
</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(', ');
|
||||
}
|
||||
|
||||
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 }) {
|
||||
const { data, loading, error, load } = useDashboardData(
|
||||
fetchDashboardPatients,
|
||||
|
|
@ -62,7 +115,7 @@ export default function PatientsSection({ onUnauthorized }) {
|
|||
'MR name',
|
||||
'Employee code',
|
||||
'Position name',
|
||||
...QUESTION_CODES.map((code) => QUESTIONS[code].label),
|
||||
...EXPORT_COLUMNS.map((col) => col.header),
|
||||
'Submitted on',
|
||||
],
|
||||
filtered.map((row) => [
|
||||
|
|
@ -71,7 +124,7 @@ export default function PatientsSection({ onUnauthorized }) {
|
|||
row.mr_name,
|
||||
row.employee_code,
|
||||
row.position_name,
|
||||
...QUESTION_CODES.map((code) => answerText(QUESTIONS[code], row.answers)),
|
||||
...EXPORT_COLUMNS.map((col) => cellForColumn(col, row.answers)),
|
||||
formatDateTime(row.created_at),
|
||||
])
|
||||
);
|
||||
|
|
@ -133,15 +186,44 @@ export default function PatientsSection({ onUnauthorized }) {
|
|||
<table className="dash-table dash-table-wide">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>No</th>
|
||||
<th className="text-left">Doctor name</th>
|
||||
<th className="text-left">MR name</th>
|
||||
<th>Employee code</th>
|
||||
<th>Position name</th>
|
||||
{QUESTION_CODES.map((code) => (
|
||||
<th key={code} className="dash-question">{QUESTIONS[code].label}</th>
|
||||
))}
|
||||
<th>Submitted on</th>
|
||||
<th rowSpan={2}>No</th>
|
||||
<th className="text-left" rowSpan={2}>Doctor name</th>
|
||||
<th className="text-left" rowSpan={2}>MR name</th>
|
||||
<th rowSpan={2}>Employee code</th>
|
||||
<th rowSpan={2}>Position name</th>
|
||||
{QUESTION_CODES.map((code) => {
|
||||
const question = QUESTIONS[code];
|
||||
if (question?.type === 'multi' && question.options?.length) {
|
||||
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>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -152,11 +234,21 @@ export default function PatientsSection({ onUnauthorized }) {
|
|||
<td className="text-left">{row.mr_name || '-'}</td>
|
||||
<td>{row.employee_code || '-'}</td>
|
||||
<td>{row.position_name || '-'}</td>
|
||||
{QUESTION_CODES.map((code) => (
|
||||
<td key={code} className="text-left dash-answer">
|
||||
{answerText(QUESTIONS[code], row.answers)}
|
||||
</td>
|
||||
))}
|
||||
{QUESTION_CODES.flatMap((code) => {
|
||||
const question = QUESTIONS[code];
|
||||
if (question?.type === 'multi' && question.options?.length) {
|
||||
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>
|
||||
</tr>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ export default function QuestionField({ question, value, otherValue, onChange, o
|
|||
<legend className="field-label">
|
||||
{question.label} <span className="req">*</span>
|
||||
</legend>
|
||||
<p className="field-hint">Multiple answers can be selected</p>
|
||||
<div className="option-list">
|
||||
{question.options.map((opt) => {
|
||||
const checked = selected.includes(opt.value);
|
||||
|
|
|
|||
|
|
@ -4,8 +4,30 @@ import { fetchTmUsers } from '../api';
|
|||
import DashboardLoading from './DashboardLoading';
|
||||
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 };
|
||||
|
||||
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 }) {
|
||||
const [items, setItems] = useState([]);
|
||||
const [totals, setTotals] = useState(EMPTY_TOTALS);
|
||||
|
|
@ -47,7 +69,7 @@ export default function TmUsersSection({ onUnauthorized }) {
|
|||
const handleExport = () => {
|
||||
exportCsv(
|
||||
'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) => [
|
||||
row.user_id,
|
||||
row.name,
|
||||
|
|
@ -67,12 +89,12 @@ export default function TmUsersSection({ onUnauthorized }) {
|
|||
<small>Total MR Count</small>
|
||||
<strong>{loading ? '…' : totals.tms}</strong>
|
||||
</div>
|
||||
<div className="dash-stat info">
|
||||
<small>Total Contacts Created</small>
|
||||
<div className="dash-stat info emphasized">
|
||||
<small>Total Doctors Enrolled</small>
|
||||
<strong>{loading ? '…' : totals.contacts_created}</strong>
|
||||
</div>
|
||||
<div className="dash-stat warning">
|
||||
<small>Total Doctors with Surveys</small>
|
||||
<div className="dash-stat warning emphasized">
|
||||
<small>Active Doctors</small>
|
||||
<strong>{loading ? '…' : totals.doctors_with_surveys}</strong>
|
||||
</div>
|
||||
<div className="dash-stat success">
|
||||
|
|
@ -129,21 +151,24 @@ export default function TmUsersSection({ onUnauthorized }) {
|
|||
<th className="text-left">Name</th>
|
||||
<th>Employee code</th>
|
||||
<th>Position name</th>
|
||||
<th>Contacts created</th>
|
||||
<th>Doctors with surveys</th>
|
||||
<th className="dash-col-emphasis">Doctors Enrolled</th>
|
||||
<th className="dash-col-emphasis">Active Doctors</th>
|
||||
<th>Total surveys</th>
|
||||
<th>Doctors</th>
|
||||
<th>WhatsApp</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((row, index) => (
|
||||
{filtered.map((row, index) => {
|
||||
const whatsapp = mrWhatsappLink(row);
|
||||
return (
|
||||
<tr key={row.user_id}>
|
||||
<td>{index + 1}</td>
|
||||
<td className="text-left">{row.name || '-'}</td>
|
||||
<td>{row.employee_code || '-'}</td>
|
||||
<td>{row.position_name || '-'}</td>
|
||||
<td className="dash-num">{row.contacts_created}</td>
|
||||
<td className="dash-num">{row.doctors_with_surveys}</td>
|
||||
<td className="dash-num dash-col-emphasis">{row.contacts_created}</td>
|
||||
<td className="dash-num dash-col-emphasis">{row.doctors_with_surveys}</td>
|
||||
<td className="dash-num">{row.total_surveys}</td>
|
||||
<td>
|
||||
{row.contacts_created > 0 ? (
|
||||
|
|
@ -163,8 +188,23 @@ export default function TmUsersSection({ onUnauthorized }) {
|
|||
<span className="dash-muted">-</span>
|
||||
)}
|
||||
</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>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -364,3 +364,83 @@
|
|||
gap: 0.75rem;
|
||||
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 { fetchDoctor, fetchPatients, loginDoctorByPhone, submitSurvey } from '../api';
|
||||
import {
|
||||
fetchDoctor,
|
||||
fetchPatients,
|
||||
fetchSurveyDraft,
|
||||
loginDoctorByPhone,
|
||||
saveSurveyDraft,
|
||||
submitSurvey,
|
||||
} from '../api';
|
||||
import QuestionField from '../components/QuestionField';
|
||||
import {
|
||||
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() {
|
||||
const { userId, contactId } = useParams();
|
||||
const [loading, setLoading] = useState(Boolean(contactId));
|
||||
|
|
@ -57,6 +76,11 @@ export default function SurveyPage() {
|
|||
const [answers, setAnswers] = useState(emptyAnswers);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
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) => {
|
||||
if (!doctorContactId) {
|
||||
|
|
@ -110,6 +134,47 @@ export default function SurveyPage() {
|
|||
}
|
||||
}, [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) => {
|
||||
event.preventDefault();
|
||||
setLoginError('');
|
||||
|
|
@ -125,17 +190,36 @@ export default function SurveyPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const openAddPatient = () => {
|
||||
const openAddPatient = async () => {
|
||||
skipAutosave.current = true;
|
||||
setAnswers(emptyAnswers());
|
||||
setStepIndex(0);
|
||||
setFormError('');
|
||||
setDraftNotice('');
|
||||
setDraftStatus('');
|
||||
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 = () => {
|
||||
skipAutosave.current = true;
|
||||
setAnswers(emptyAnswers());
|
||||
setStepIndex(0);
|
||||
setFormError('');
|
||||
setDraftNotice('');
|
||||
setDraftStatus('');
|
||||
setView('list');
|
||||
};
|
||||
|
||||
|
|
@ -180,6 +264,11 @@ export default function SurveyPage() {
|
|||
setStepIndex((i) => Math.max(0, i - 1));
|
||||
};
|
||||
|
||||
const handleSaveDraft = async () => {
|
||||
setFormError('');
|
||||
await persistDraft(answers, { silent: false });
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const err = validateStep(currentStep.id, answers);
|
||||
if (err) {
|
||||
|
|
@ -198,8 +287,11 @@ export default function SurveyPage() {
|
|||
contact_id: doctor.contact_id,
|
||||
answers,
|
||||
});
|
||||
skipAutosave.current = true;
|
||||
setAnswers(emptyAnswers());
|
||||
setStepIndex(0);
|
||||
setDraftNotice('');
|
||||
setDraftStatus('');
|
||||
setView('list');
|
||||
await loadPatients(doctor.contact_id);
|
||||
} catch (e) {
|
||||
|
|
@ -219,6 +311,8 @@ export default function SurveyPage() {
|
|||
setError('');
|
||||
setPatients([]);
|
||||
setPatientsError('');
|
||||
setDraftNotice('');
|
||||
setDraftStatus('');
|
||||
setView('list');
|
||||
};
|
||||
|
||||
|
|
@ -362,6 +456,9 @@ export default function SurveyPage() {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{draftNotice ? <p className="muted small">{draftNotice}</p> : null}
|
||||
{draftStatus ? <p className="muted small">{draftStatus}</p> : null}
|
||||
|
||||
<div className="progress-wrap">
|
||||
<div className="progress-bar" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
|
|
@ -386,10 +483,18 @@ export default function SurveyPage() {
|
|||
{formError ? <p className="error">{formError}</p> : null}
|
||||
|
||||
<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'}
|
||||
</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…'
|
||||
: currentStep.id === 'cough' && answers.has_cough === 'no'
|
||||
|
|
|
|||
Loading…
Reference in New Issue