This commit is contained in:
Ravi Gulhane 2026-11-07 11:42:00 +05:30
parent 4acc4fb84c
commit 2bbfc622d9
17 changed files with 1107 additions and 78 deletions

View File

@ -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=

View File

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

View File

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

View File

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

View File

@ -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),
];

View File

@ -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',

View File

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

View File

@ -41,6 +41,7 @@ const init = async () => {
...require('./routes/dashboardRoutes'),
...require('./routes/doctorRoutes'),
...require('./routes/surveyRoutes'),
...require('./routes/whatsappRoutes'),
]);
await initializeDb();

View File

@ -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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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>

View File

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

View File

@ -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'