From 2bbfc622d9787b0e4bea7965bf3c1adc69910ef1 Mon Sep 17 00:00:00 2001 From: Ravi Gulhane <> Date: Sat, 7 Nov 2026 11:42:00 +0530 Subject: [PATCH] Changes --- backend/.env.example | 3 + backend/src/config/db.js | 13 + .../src/controllers/dashboardController.js | 117 ++++++- backend/src/controllers/surveyController.js | 155 ++++++++- backend/src/routes/dashboardRoutes.js | 24 +- backend/src/routes/surveyRoutes.js | 34 +- backend/src/seed.js | 43 +-- backend/src/server.js | 1 + backend/src/services/rapport.js | 48 ++- frontend/src/App.css | 6 + frontend/src/api.js | 32 ++ frontend/src/components/DoctorsSection.jsx | 327 +++++++++++++++++- frontend/src/components/PatientsSection.jsx | 124 ++++++- frontend/src/components/QuestionField.jsx | 1 + frontend/src/components/TmUsersSection.jsx | 62 +++- frontend/src/pages/Dashboard.css | 80 +++++ frontend/src/pages/SurveyPage.jsx | 115 +++++- 17 files changed, 1107 insertions(+), 78 deletions(-) diff --git a/backend/.env.example b/backend/.env.example index 160670a..c6ac19e 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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= diff --git a/backend/src/config/db.js b/backend/src/config/db.js index bd0ea41..de261bc 100644 --- a/backend/src/config/db.js +++ b/backend/src/config/db.js @@ -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(); } diff --git a/backend/src/controllers/dashboardController.js b/backend/src/controllers/dashboardController.js index 4292ce9..0083ebb 100644 --- a/backend/src/controllers/dashboardController.js +++ b/backend/src/controllers/dashboardController.js @@ -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 }; diff --git a/backend/src/controllers/surveyController.js b/backend/src/controllers/surveyController.js index 736b82c..27c5ff1 100644 --- a/backend/src/controllers/surveyController.js +++ b/backend/src/controllers/surveyController.js @@ -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, +}; diff --git a/backend/src/routes/dashboardRoutes.js b/backend/src/routes/dashboardRoutes.js index a043864..366f580 100644 --- a/backend/src/routes/dashboardRoutes.js +++ b/backend/src/routes/dashboardRoutes.js @@ -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), ]; diff --git a/backend/src/routes/surveyRoutes.js b/backend/src/routes/surveyRoutes.js index f3c7889..d2d577c 100644 --- a/backend/src/routes/surveyRoutes.js +++ b/backend/src/routes/surveyRoutes.js @@ -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', diff --git a/backend/src/seed.js b/backend/src/seed.js index 5d23589..807edc5 100644 --- a/backend/src/seed.js +++ b/backend/src/seed.js @@ -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 }; diff --git a/backend/src/server.js b/backend/src/server.js index 52af403..90fb4e3 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -41,6 +41,7 @@ const init = async () => { ...require('./routes/dashboardRoutes'), ...require('./routes/doctorRoutes'), ...require('./routes/surveyRoutes'), + ...require('./routes/whatsappRoutes'), ]); await initializeDb(); diff --git a/backend/src/services/rapport.js b/backend/src/services/rapport.js index b706e9a..17940fe 100644 --- a/backend/src/services/rapport.js +++ b/backend/src/services/rapport.js @@ -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, diff --git a/frontend/src/App.css b/frontend/src/App.css index dd50a1d..d327572 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -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; diff --git a/frontend/src/api.js b/frontend/src/api.js index 28ce958..17bdbb9 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -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' : ''}`); } diff --git a/frontend/src/components/DoctorsSection.jsx b/frontend/src/components/DoctorsSection.jsx index 7e9507a..581127a 100644 --- a/frontend/src/components/DoctorsSection.jsx +++ b/frontend/src/components/DoctorsSection.jsx @@ -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 }) { Position name Total surveys WhatsApp + Edit @@ -170,7 +304,7 @@ export default function DoctorsSection({ onUnauthorized }) { {index + 1} {row.name || '-'} - {row.msl_code || '-'} + {row.msl_code || row.wfsa_code || '-'} {row.phone || '-'} {row.mr_name || '-'} {row.employee_code || '-'} @@ -184,12 +318,21 @@ export default function DoctorsSection({ onUnauthorized }) { target="_blank" rel="noopener noreferrer" > - Send WhatsApp + Send ) : ( {whatsapp.label} )} + + + ); })} @@ -198,6 +341,176 @@ export default function DoctorsSection({ onUnauthorized }) { ) : null} + + {editing ? ( +
+
e.stopPropagation()} + > +
+

Edit doctor

+ +
+
+ + + + + + + + + + + {form.qualification === 'Others' ? ( + + ) : null} + + + {saveError ?

{saveError}

: null} + +
+ + +
+
+
+
+ ) : null} ); } diff --git a/frontend/src/components/PatientsSection.jsx b/frontend/src/components/PatientsSection.jsx index 00ad5c7..a24d259 100644 --- a/frontend/src/components/PatientsSection.jsx +++ b/frontend/src/components/PatientsSection.jsx @@ -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 }) { - - - - - - {QUESTION_CODES.map((code) => ( - - ))} - + + + + + + {QUESTION_CODES.map((code) => { + const question = QUESTIONS[code]; + if (question?.type === 'multi' && question.options?.length) { + return ( + + ); + } + return ( + + ); + })} + + + + {QUESTION_CODES.flatMap((code) => { + const question = QUESTIONS[code]; + if (question?.type !== 'multi' || !question.options?.length) { + return []; + } + return question.options.map((opt) => ( + + )); + })} @@ -152,11 +234,21 @@ export default function PatientsSection({ onUnauthorized }) { - {QUESTION_CODES.map((code) => ( - - ))} + {QUESTION_CODES.flatMap((code) => { + const question = QUESTIONS[code]; + if (question?.type === 'multi' && question.options?.length) { + return question.options.map((opt) => ( + + )); + } + return [ + , + ]; + })} ))} diff --git a/frontend/src/components/QuestionField.jsx b/frontend/src/components/QuestionField.jsx index 74cb6d7..d5cfd48 100644 --- a/frontend/src/components/QuestionField.jsx +++ b/frontend/src/components/QuestionField.jsx @@ -76,6 +76,7 @@ export default function QuestionField({ question, value, otherValue, onChange, o {question.label} * +

Multiple answers can be selected

{question.options.map((opt) => { const checked = selected.includes(opt.value); diff --git a/frontend/src/components/TmUsersSection.jsx b/frontend/src/components/TmUsersSection.jsx index a4bb560..3c085c6 100644 --- a/frontend/src/components/TmUsersSection.jsx +++ b/frontend/src/components/TmUsersSection.jsx @@ -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 }) { Total MR Count {loading ? '…' : totals.tms}
-
- Total Contacts Created +
+ Total Doctors Enrolled {loading ? '…' : totals.contacts_created}
-
- Total Doctors with Surveys +
+ Active Doctors {loading ? '…' : totals.doctors_with_surveys}
@@ -129,21 +151,24 @@ export default function TmUsersSection({ onUnauthorized }) {
- - + + + - {filtered.map((row, index) => ( + {filtered.map((row, index) => { + const whatsapp = mrWhatsappLink(row); + return ( - - + + + - ))} + ); + })}
NoDoctor nameMR nameEmployee codePosition name{QUESTIONS[code].label}Submitted onNoDoctor nameMR nameEmployee codePosition name + {question.label} + + {question.label} + Submitted on
+ {opt.label} +
{row.mr_name || '-'} {row.employee_code || '-'} {row.position_name || '-'} - {answerText(QUESTIONS[code], row.answers)} - + {optionCell(question, row.answers, opt.value) || '-'} + + {answerText(question, row.answers)} + {formatDateTime(row.created_at)}
Name Employee code Position nameContacts createdDoctors with surveysDoctors EnrolledActive Doctors Total surveys DoctorsWhatsApp
{index + 1} {row.name || '-'} {row.employee_code || '-'} {row.position_name || '-'}{row.contacts_created}{row.doctors_with_surveys}{row.contacts_created}{row.doctors_with_surveys} {row.total_surveys} {row.contacts_created > 0 ? ( @@ -163,8 +188,23 @@ export default function TmUsersSection({ onUnauthorized }) { - )} + {whatsapp.href ? ( + + Send WhatsApp + + ) : ( + {whatsapp.label} + )} +
diff --git a/frontend/src/pages/Dashboard.css b/frontend/src/pages/Dashboard.css index ed6a055..f702d6f 100644 --- a/frontend/src/pages/Dashboard.css +++ b/frontend/src/pages/Dashboard.css @@ -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; +} diff --git a/frontend/src/pages/SurveyPage.jsx b/frontend/src/pages/SurveyPage.jsx index bc16d71..bc9533c 100644 --- a/frontend/src/pages/SurveyPage.jsx +++ b/frontend/src/pages/SurveyPage.jsx @@ -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() { + {draftNotice ?

{draftNotice}

: null} + {draftStatus ?

{draftStatus}

: null} +
@@ -386,10 +483,18 @@ export default function SurveyPage() { {formError ?

{formError}

: null}
- - +