diff --git a/backend/.env.example b/backend/.env.example
index cf36add..160670a 100644
--- a/backend/.env.example
+++ b/backend/.env.example
@@ -21,6 +21,9 @@ RAPPORT_API_BASE=https://rapportapi.click/
RAPPORT_ACCESS_TOKEN=
RAPPORT_PARTITION=rapport-lifemankind
+# Dashboard TM list: comma-separated Rapport role ids
+TGICS_TM_ROLE_IDS=1655449171-a39a5017-8232-4d58-L1-90d06bc733d0
+
# Dashboard auth
JWT_SECRET=
JWT_EXPIRES_IN=7d
diff --git a/backend/nodemon.json b/backend/nodemon.json
new file mode 100644
index 0000000..ed9277c
--- /dev/null
+++ b/backend/nodemon.json
@@ -0,0 +1,4 @@
+{
+ "watch": ["src", ".env"],
+ "ext": "js,json,env"
+}
diff --git a/backend/src/controllers/dashboardController.js b/backend/src/controllers/dashboardController.js
index 9ee2e4e..4292ce9 100644
--- a/backend/src/controllers/dashboardController.js
+++ b/backend/src/controllers/dashboardController.js
@@ -1,4 +1,12 @@
const { pool } = require('../config/db');
+const { getUsersByRoles, getPositionsByOwner, getEnrolledContacts, getContactById, } = require('../services/rapport');
+
+function tmRoleIds() {
+ return String(process.env.TGICS_TM_ROLE_IDS || '')
+ .split(',')
+ .map((r) => r.trim())
+ .filter(Boolean);
+}
async function listSurveys(request, h) {
try {
@@ -52,4 +60,201 @@ async function surveyStats(request, h) {
}
}
-module.exports = { listSurveys, surveyStats };
+async function getTmUsers(request, h) {
+ const roleIds = tmRoleIds();
+ if (!roleIds.length) {
+ return h.response({ status: 'error', message: 'TGICS_TM_ROLE_IDS is not configured.' }).code(503);
+ }
+
+ try {
+ const refresh = String(request.query.refresh || '') === '1';
+ const [users, positions, enrolled, [surveyRows]] = await Promise.all([
+ getUsersByRoles(roleIds, { refresh }),
+ getPositionsByOwner({ refresh }),
+ getEnrolledContacts({ refresh }),
+ pool.query(
+ `SELECT user_id, COUNT(*) AS total_surveys, COUNT(DISTINCT contact_id) AS doctors_with_surveys
+ FROM surveys
+ GROUP BY user_id`
+ ),
+ ]);
+
+ const contactsByUser = new Map();
+ for (const contact of enrolled) {
+ contactsByUser.set(contact.user_id, (contactsByUser.get(contact.user_id) || 0) + 1);
+ }
+ const surveysByUser = new Map(
+ surveyRows.map((row) => [String(row.user_id), {
+ total_surveys: Number(row.total_surveys) || 0,
+ doctors_with_surveys: Number(row.doctors_with_surveys) || 0,
+ }])
+ );
+
+ const items = users
+ .map((user) => {
+ const position = positions.get(user.id) || {};
+ const surveys = surveysByUser.get(user.id) || {};
+ return {
+ user_id: user.id,
+ name: `${user.firstname || ''} ${user.lastname || ''}`.trim(),
+ employee_code: user.employee_code || '',
+ designation: position.designation || user.designation || '',
+ position_name: position.position_name || '',
+ position_code: position.position_code || '',
+ contacts_created: contactsByUser.get(user.id) || 0,
+ doctors_with_surveys: surveys.doctors_with_surveys || 0,
+ total_surveys: surveys.total_surveys || 0,
+ };
+ })
+ .sort((a, b) =>
+ b.total_surveys - a.total_surveys
+ || b.contacts_created - a.contacts_created
+ || a.name.localeCompare(b.name)
+ );
+
+ const totals = items.reduce(
+ (acc, row) => {
+ acc.contacts_created += row.contacts_created;
+ acc.doctors_with_surveys += row.doctors_with_surveys;
+ acc.total_surveys += row.total_surveys;
+ return acc;
+ },
+ { tms: items.length, contacts_created: 0, doctors_with_surveys: 0, total_surveys: 0 }
+ );
+
+ return h.response({
+ status: 'success',
+ data: { role_ids: roleIds, items, total: items.length, totals },
+ }).code(200);
+ } catch (err) {
+ console.error('getTmUsers error', err);
+ return h.response({
+ status: 'error',
+ message: err.message || 'Unable to load TM users.',
+ }).code(err.statusCode || 500);
+ }
+}
+
+function parseAnswers(raw) {
+ if (raw && typeof raw === 'object') return raw;
+ try {
+ const parsed = JSON.parse(raw || '{}');
+ return parsed && typeof parsed === 'object' ? parsed : {};
+ } catch {
+ return {};
+ }
+}
+
+function mrDetails(userId, positions, fallback = {}) {
+ const owner = positions.get(userId) || {};
+ return {
+ mr_name: owner.name || fallback.user_name || '',
+ employee_code: owner.employee_code || '',
+ position_name: owner.position_name || fallback.position_name || '',
+ };
+}
+
+async function getDoctors(request, h) {
+ try {
+ const refresh = String(request.query.refresh || '') === '1';
+ const [contacts, positions, [surveyRows]] = await Promise.all([
+ getEnrolledContacts({ refresh }),
+ getPositionsByOwner({ refresh }),
+ pool.query(
+ `SELECT contact_id, COUNT(*) AS total_surveys
+ FROM surveys
+ GROUP BY contact_id`
+ ),
+ ]);
+
+ const surveysByContact = new Map(surveyRows.map((row) => [String(row.contact_id), row]));
+
+ const items = contacts.map((contact) => {
+ const surveys = surveysByContact.get(contact.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 || '',
+ ...mrDetails(contact.user_id, positions, contact),
+ total_surveys: surveys ? Number(surveys.total_surveys) || 0 : 0,
+ };
+ }).sort((a, b) => b.total_surveys - a.total_surveys || a.name.localeCompare(b.name));
+
+ const totals = {
+ doctors: items.length,
+ doctors_with_surveys: items.filter((d) => d.total_surveys > 0).length,
+ total_surveys: items.reduce((sum, d) => sum + d.total_surveys, 0),
+ };
+
+ return h.response({
+ status: 'success',
+ data: { items, totals },
+ }).code(200);
+ } catch (err) {
+ console.error('getDoctors error', err);
+ return h.response({
+ status: 'error',
+ message: err.message || 'Unable to load doctors.',
+ }).code(err.statusCode || 500);
+ }
+}
+
+async function getPatients(request, h) {
+ try {
+ const refresh = String(request.query.refresh || '') === '1';
+ const [[rows], contacts, positions] = await Promise.all([
+ pool.query(
+ `SELECT id, contact_id, user_id, has_cough, answers_json, created_at
+ FROM surveys
+ ORDER BY created_at DESC, id DESC`
+ ),
+ getEnrolledContacts({ refresh }),
+ getPositionsByOwner({ refresh }),
+ ]);
+
+ const contactsById = new Map(contacts.map((c) => [c.id, c]));
+ for (const id of new Set(rows.map((r) => String(r.contact_id)))) {
+ if (contactsById.has(id)) continue;
+ try {
+ const contact = await getContactById(id);
+ if (contact) contactsById.set(id, contact);
+ } catch (err) {
+ console.warn('getPatients contact lookup failed', id, err.message);
+ }
+ }
+
+ const items = rows.map((row) => {
+ const contact = contactsById.get(String(row.contact_id)) || {};
+ return {
+ id: row.id,
+ created_at: row.created_at,
+ contact_id: row.contact_id,
+ doctor_name: contact.name || '',
+ user_id: row.user_id,
+ ...mrDetails(String(row.user_id), positions, contact),
+ has_cough: Boolean(row.has_cough),
+ answers: parseAnswers(row.answers_json),
+ };
+ });
+
+ const totals = {
+ total_surveys: items.length,
+ };
+
+ return h.response({
+ status: 'success',
+ data: { items, totals },
+ }).code(200);
+ } catch (err) {
+ console.error('getPatients error', err);
+ return h.response({
+ status: 'error',
+ message: err.message || 'Unable to load patients.',
+ }).code(err.statusCode || 500);
+ }
+}
+
+module.exports = { listSurveys, surveyStats, getTmUsers, getDoctors, getPatients };
diff --git a/backend/src/routes/dashboardRoutes.js b/backend/src/routes/dashboardRoutes.js
index 728ef4f..a043864 100644
--- a/backend/src/routes/dashboardRoutes.js
+++ b/backend/src/routes/dashboardRoutes.js
@@ -1,21 +1,19 @@
-const { listSurveys, surveyStats } = require('../controllers/dashboardController');
+const { listSurveys, surveyStats, getTmUsers, getDoctors, getPatients, } = require('../controllers/dashboardController');
const { requireAuth } = require('../middleware/auth');
+const authed = (path, handler) => ({
+ method: 'GET',
+ path,
+ options: {
+ pre: [{ method: requireAuth }],
+ },
+ handler,
+});
+
module.exports = [
- {
- method: 'GET',
- path: '/api/dashboard/stats',
- options: {
- pre: [{ method: requireAuth }],
- },
- handler: surveyStats,
- },
- {
- method: 'GET',
- path: '/api/dashboard/surveys',
- options: {
- pre: [{ method: requireAuth }],
- },
- handler: listSurveys,
- },
+ 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),
];
diff --git a/backend/src/services/rapport.js b/backend/src/services/rapport.js
index 1f1ddfd..b706e9a 100644
--- a/backend/src/services/rapport.js
+++ b/backend/src/services/rapport.js
@@ -172,7 +172,133 @@ async function resolveTgicsDoctorByPhone(userId, phone) {
return { ok: true, doctor: toDoctorPayload(contact, userId), contact };
}
+async function rapportGet(service, path, params = {}) {
+ if (!ACCESS_TOKEN) {
+ const err = new Error('RAPPORT_ACCESS_TOKEN is not configured');
+ err.statusCode = 503;
+ throw err;
+ }
+
+ const url = new URL(`${service}/${path}`, API_BASE);
+ url.searchParams.set('application', 'rapport');
+ for (const [key, value] of Object.entries(params)) {
+ if (value === undefined || value === null) continue;
+ url.searchParams.set(key, typeof value === 'object' ? JSON.stringify(value) : String(value));
+ }
+
+ const response = await fetch(url.toString(), {
+ method: 'GET',
+ headers: {
+ Authorization: ACCESS_TOKEN,
+ Accept: 'application/json',
+ },
+ });
+
+ const body = await response.json().catch(() => ({}));
+ const apiStatus = Number(body?.statusCode || response.status);
+ if (!response.ok || apiStatus >= 400) {
+ const err = new Error(body.message || `Rapport ${service}/${path} failed (${apiStatus})`);
+ err.statusCode = apiStatus === 401 ? 503 : 502;
+ throw err;
+ }
+ return body?.data || {};
+}
+
+const CACHE_TTL_MS = Number(process.env.RAPPORT_CACHE_TTL_MS || 10 * 60 * 1000);
+const cache = new Map();
+
+async function cached(key, loader, refresh = false) {
+ const hit = cache.get(key);
+ if (!refresh && hit && hit.expiresAt > Date.now()) {
+ return hit.value;
+ }
+ const value = await loader();
+ cache.set(key, { value, expiresAt: Date.now() + CACHE_TTL_MS });
+ return value;
+}
+
+/**
+ * Active users holding any of the given role ids (ums/users/dropdown, de-duplicated by id).
+ */
+async function getUsersByRoles(roleIds, { refresh = false } = {}) {
+ const ids = [...new Set((roleIds || []).map((r) => String(r).trim()).filter(Boolean))];
+ return cached(`users:${ids.join(',')}`, async () => {
+ const byId = new Map();
+ for (const roleId of ids) {
+ const data = await rapportGet('ums', 'users/dropdown', { filter: { roles: roleId } });
+ for (const user of Array.isArray(data.items) ? data.items : []) {
+ if (user?.id && !byId.has(user.id)) {
+ byId.set(user.id, user);
+ }
+ }
+ }
+ return [...byId.values()];
+ }, refresh);
+}
+
+/**
+ * Map of owner user id -> position details (ums/hierarchy/get_all with positions + export).
+ */
+async function getPositionsByOwner({ refresh = false } = {}) {
+ return cached('positions', async () => {
+ const data = await rapportGet('ums', 'hierarchy/get_all', { positions: 1, export: 1 });
+ const rows = Array.isArray(data.hierarchys) ? data.hierarchys : Array.isArray(data.items) ? data.items : [];
+ const byOwner = new Map();
+ for (const row of rows) {
+ const owner = row?.owner;
+ if (!owner || owner === 'vacant' || byOwner.has(owner)) continue;
+ const info = row.owner_info || {};
+ byOwner.set(owner, {
+ position_id: row.id || '',
+ position_name: row.name || '',
+ position_code: row.code || '',
+ designation: row.designation || '',
+ name: `${info.firstname || ''} ${info.lastname || ''}`.trim(),
+ employee_code: info.employee_code || '',
+ });
+ }
+ return byOwner;
+ }, refresh);
+}
+
+const ENROLLED_VALUES = ['Yes', 'yes', 'YES'];
+const ES_PAGE_SIZE = 1000;
+const ES_MAX_WINDOW = 10000;
+
+/**
+ * All TGICS-enrolled contacts (full get_all items).
+ * The ES term filter is case-sensitive, so each spelling of "yes" is queried and merged.
+ */
+async function getEnrolledContacts({ refresh = false } = {}) {
+ return cached('enrolled-contacts', async () => {
+ const byId = new Map();
+ for (const value of ENROLLED_VALUES) {
+ for (let start = 0; start < ES_MAX_WINDOW; start += ES_PAGE_SIZE) {
+ const data = await rapportGet('contact', 'contact/get_all', {
+ start,
+ length: ES_PAGE_SIZE,
+ keyword: '',
+ search: { attribute_tgics_enrolled_doctor: value },
+ });
+ const items = Array.isArray(data.items) ? data.items : [];
+ for (const item of items) {
+ if (item?.id && !byId.has(item.id)) {
+ byId.set(item.id, item);
+ }
+ }
+ const total = Number(data.count) || 0;
+ if (items.length < ES_PAGE_SIZE || start + ES_PAGE_SIZE >= total) break;
+ }
+ }
+ return [...byId.values()];
+ }, refresh);
+}
+
module.exports = {
+ rapportGet,
+ getUsersByRoles,
+ getPositionsByOwner,
+ getEnrolledContacts,
getContactById,
isTgicsEnrolled,
toDoctorPayload,
diff --git a/frontend/index.html b/frontend/index.html
index b48d617..070790a 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -4,6 +4,9 @@
+
+
+
TGICS — The Great Indian Cough Study
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index a38fc7b..121a4dd 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -11,7 +11,7 @@ export default function App() {
} />
} />
- } />
+ } />
} />
} />
} />
diff --git a/frontend/src/api.js b/frontend/src/api.js
index c1815f6..28ce958 100644
--- a/frontend/src/api.js
+++ b/frontend/src/api.js
@@ -81,3 +81,15 @@ export function fetchDashboardStats() {
export function fetchDashboardSurveys(limit = 50, offset = 0) {
return request(`/api/dashboard/surveys?limit=${limit}&offset=${offset}`);
}
+
+export function fetchTmUsers(refresh = false) {
+ return request(`/api/dashboard/tm-users${refresh ? '?refresh=1' : ''}`);
+}
+
+export function fetchDashboardDoctors(refresh = false) {
+ return request(`/api/dashboard/doctors${refresh ? '?refresh=1' : ''}`);
+}
+
+export function fetchDashboardPatients(refresh = false) {
+ return request(`/api/dashboard/patients${refresh ? '?refresh=1' : ''}`);
+}
diff --git a/frontend/src/components/DashboardLoading.jsx b/frontend/src/components/DashboardLoading.jsx
new file mode 100644
index 0000000..153d79c
--- /dev/null
+++ b/frontend/src/components/DashboardLoading.jsx
@@ -0,0 +1,8 @@
+export default function DashboardLoading() {
+ return (
+
+
+ Loading data…
+
+ );
+}
diff --git a/frontend/src/components/DoctorsSection.jsx b/frontend/src/components/DoctorsSection.jsx
new file mode 100644
index 0000000..a44e34a
--- /dev/null
+++ b/frontend/src/components/DoctorsSection.jsx
@@ -0,0 +1,161 @@
+import { useMemo, useState } from 'react';
+import { useSearchParams } from 'react-router-dom';
+import { fetchDashboardDoctors } from '../api';
+import DashboardLoading from './DashboardLoading';
+import exportCsv from './exportCsv';
+import useDashboardData, { matchesQuery } from './useDashboardData';
+
+export default function DoctorsSection({ onUnauthorized }) {
+ const { data, loading, error, load } = useDashboardData(
+ fetchDashboardDoctors,
+ onUnauthorized,
+ 'Unable to load doctors.'
+ );
+ const [query, setQuery] = useState('');
+ const [searchParams] = useSearchParams();
+ const mrId = searchParams.get('mr') || '';
+ const mrName = searchParams.get('mr_name') || '';
+ const mrCode = searchParams.get('mr_code') || '';
+
+ const items = useMemo(() => {
+ const all = data?.items || [];
+ return mrId ? all.filter((row) => row.user_id === mrId) : all;
+ }, [data, mrId]);
+
+ const totals = useMemo(() => ({
+ doctors: items.length,
+ doctors_with_surveys: items.filter((row) => row.total_surveys > 0).length,
+ total_surveys: items.reduce((sum, row) => sum + row.total_surveys, 0),
+ }), [items]);
+
+ const filtered = useMemo(
+ () => items.filter((row) =>
+ matchesQuery(
+ [row.name, row.msl_code, row.phone, row.mr_name, row.employee_code, row.position_name],
+ query
+ )
+ ),
+ [items, query]
+ );
+
+ const value = (n) => (loading ? '…' : n ?? 0);
+
+ const handleExport = () => {
+ const name = mrId ? `tgics_doctors_${mrCode || mrId}` : 'tgics_doctors';
+ exportCsv(
+ name,
+ ['Doctor ID', 'Doctor name', 'Code', 'Phone', 'MR name', 'Employee code', 'Position name', 'Total surveys'],
+ filtered.map((row) => [
+ row.contact_id,
+ row.name,
+ row.msl_code,
+ row.phone,
+ row.mr_name,
+ row.employee_code,
+ row.position_name,
+ row.total_surveys,
+ ])
+ );
+ };
+
+ return (
+ <>
+ {mrId ? (
+
+
+ Doctors of {mrName || 'selected MR'}
+ {mrCode ? ` (${mrCode})` : ''}
+
+
+ ) : null}
+
+
+
+ Total Doctors
+ {value(totals.doctors)}
+
+
+ Total Doctors with Surveys
+ {value(totals.doctors_with_surveys)}
+
+
+ Total Surveys
+ {value(totals.total_surveys)}
+
+
+
+
+
+
+ {loading
+ ? ''
+ : query.trim()
+ ? `${filtered.length} of ${items.length} doctors`
+ : `${items.length} doctors`}
+
+
+ setQuery(e.target.value)}
+ />
+
+
+
+
+
+ {error ? {error}
: null}
+
+ {loading ? : null}
+
+ {!loading && !error && filtered.length === 0 ? (
+ No doctors found.
+ ) : null}
+
+ {filtered.length > 0 ? (
+
+
+
+
+ | No |
+ Doctor name |
+ Code |
+ Phone |
+ MR name |
+ Employee code |
+ Position name |
+ Total surveys |
+
+
+
+ {filtered.map((row, index) => (
+
+ | {index + 1} |
+ {row.name || '-'} |
+ {row.msl_code || '-'} |
+ {row.phone || '-'} |
+ {row.mr_name || '-'} |
+ {row.employee_code || '-'} |
+ {row.position_name || '-'} |
+ {row.total_surveys} |
+
+ ))}
+
+
+
+ ) : null}
+
+ >
+ );
+}
diff --git a/frontend/src/components/PatientsSection.jsx b/frontend/src/components/PatientsSection.jsx
new file mode 100644
index 0000000..00ad5c7
--- /dev/null
+++ b/frontend/src/components/PatientsSection.jsx
@@ -0,0 +1,192 @@
+import { useMemo, useState } from 'react';
+import { fetchDashboardPatients } from '../api';
+import { QUESTIONS, STEPS } from '../questionnaire';
+import useDashboardData, { formatDateTime, matchesQuery } from './useDashboardData';
+import DashboardLoading from './DashboardLoading';
+import exportCsv from './exportCsv';
+
+const PAGE_SIZE = 50;
+const QUESTION_CODES = STEPS.flatMap((step) => step.fields);
+
+function answerText(question, answers) {
+ const raw = answers?.[question.code];
+ if (raw === undefined || raw === null || raw === '' || (Array.isArray(raw) && raw.length === 0)) {
+ return '-';
+ }
+ if (!question.options) return String(raw);
+
+ const labelOf = (value) => {
+ if (value === 'others' && question.otherField) {
+ const other = String(answers[question.otherField] || '').trim();
+ return other ? `Others: ${other}` : 'Others';
+ }
+ return question.options.find((o) => o.value === value)?.label || value;
+ };
+ return (Array.isArray(raw) ? raw : [raw]).map(labelOf).join(', ');
+}
+
+export default function PatientsSection({ onUnauthorized }) {
+ const { data, loading, error, load } = useDashboardData(
+ fetchDashboardPatients,
+ onUnauthorized,
+ 'Unable to load patients.'
+ );
+ const [query, setQuery] = useState('');
+ const [page, setPage] = useState(1);
+
+ const items = useMemo(() => data?.items || [], [data]);
+ const totals = data?.totals || {};
+
+ const filtered = useMemo(
+ () => items.filter((row) =>
+ matchesQuery(
+ [row.doctor_name, row.mr_name, row.employee_code, row.position_name],
+ query
+ )
+ ),
+ [items, query]
+ );
+
+ const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
+ const currentPage = Math.min(page, pageCount);
+ const pageRows = filtered.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE);
+
+ const value = (n) => (loading ? '…' : n ?? 0);
+
+ const handleExport = () => {
+ exportCsv(
+ 'tgics_patients',
+ [
+ 'Patient ID',
+ 'Doctor name',
+ 'MR name',
+ 'Employee code',
+ 'Position name',
+ ...QUESTION_CODES.map((code) => QUESTIONS[code].label),
+ 'Submitted on',
+ ],
+ filtered.map((row) => [
+ row.id,
+ row.doctor_name,
+ row.mr_name,
+ row.employee_code,
+ row.position_name,
+ ...QUESTION_CODES.map((code) => answerText(QUESTIONS[code], row.answers)),
+ formatDateTime(row.created_at),
+ ])
+ );
+ };
+
+ return (
+ <>
+
+
+ Total Surveys
+ {value(totals.total_surveys)}
+
+
+
+
+
+
+ {loading
+ ? ''
+ : query.trim()
+ ? `${filtered.length} of ${items.length} patients`
+ : `${items.length} patients`}
+
+
+ {
+ setQuery(e.target.value);
+ setPage(1);
+ }}
+ />
+
+
+
+
+
+ {error ? {error}
: null}
+
+ {loading ? : null}
+
+ {!loading && !error && filtered.length === 0 ? (
+ No patients found.
+ ) : null}
+
+ {pageRows.length > 0 ? (
+
+
+
+
+ | No |
+ Doctor name |
+ MR name |
+ Employee code |
+ Position name |
+ {QUESTION_CODES.map((code) => (
+ {QUESTIONS[code].label} |
+ ))}
+ Submitted on |
+
+
+
+ {pageRows.map((row, index) => (
+
+ | {(currentPage - 1) * PAGE_SIZE + index + 1} |
+ {row.doctor_name || '-'} |
+ {row.mr_name || '-'} |
+ {row.employee_code || '-'} |
+ {row.position_name || '-'} |
+ {QUESTION_CODES.map((code) => (
+
+ {answerText(QUESTIONS[code], row.answers)}
+ |
+ ))}
+ {formatDateTime(row.created_at)} |
+
+ ))}
+
+
+
+ ) : null}
+
+ {pageCount > 1 ? (
+
+
+ Page {currentPage} of {pageCount}
+
+
+ ) : null}
+
+ >
+ );
+}
diff --git a/frontend/src/components/TmUsersSection.jsx b/frontend/src/components/TmUsersSection.jsx
new file mode 100644
index 0000000..a4bb560
--- /dev/null
+++ b/frontend/src/components/TmUsersSection.jsx
@@ -0,0 +1,175 @@
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { Link } from 'react-router-dom';
+import { fetchTmUsers } from '../api';
+import DashboardLoading from './DashboardLoading';
+import exportCsv from './exportCsv';
+
+const EMPTY_TOTALS = { tms: 0, contacts_created: 0, doctors_with_surveys: 0, total_surveys: 0 };
+
+export default function TmUsersSection({ onUnauthorized }) {
+ const [items, setItems] = useState([]);
+ const [totals, setTotals] = useState(EMPTY_TOTALS);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState('');
+ const [query, setQuery] = useState('');
+
+ const load = useCallback(async (refresh = false) => {
+ setLoading(true);
+ setError('');
+ try {
+ const res = await fetchTmUsers(refresh);
+ setItems(Array.isArray(res.data?.items) ? res.data.items : []);
+ setTotals({ ...EMPTY_TOTALS, ...(res.data?.totals || {}) });
+ } catch (err) {
+ if (err.status === 401) {
+ onUnauthorized?.();
+ return;
+ }
+ setError(err.message || 'Unable to load Territory Managers.');
+ } finally {
+ setLoading(false);
+ }
+ }, [onUnauthorized]);
+
+ useEffect(() => {
+ load(false);
+ }, [load]);
+
+ const filtered = useMemo(() => {
+ const q = query.trim().toLowerCase();
+ if (!q) return items;
+ return items.filter((row) =>
+ [row.name, row.employee_code, row.position_name, row.position_code]
+ .some((v) => String(v || '').toLowerCase().includes(q))
+ );
+ }, [items, query]);
+
+ const handleExport = () => {
+ exportCsv(
+ 'tgics_mr_dashboard',
+ ['MR ID', 'Name', 'Employee code', 'Position name', 'Contacts created', 'Doctors with surveys', 'Total surveys'],
+ filtered.map((row) => [
+ row.user_id,
+ row.name,
+ row.employee_code,
+ row.position_name,
+ row.contacts_created,
+ row.doctors_with_surveys,
+ row.total_surveys,
+ ])
+ );
+ };
+
+ return (
+ <>
+
+
+ Total MR Count
+ {loading ? '…' : totals.tms}
+
+
+ Total Contacts Created
+ {loading ? '…' : totals.contacts_created}
+
+
+ Total Doctors with Surveys
+ {loading ? '…' : totals.doctors_with_surveys}
+
+
+ Total Surveys
+ {loading ? '…' : totals.total_surveys}
+
+
+
+
+
+
+ {loading
+ ? ''
+ : query.trim()
+ ? `${filtered.length} of ${items.length} MRs`
+ : `${items.length} MRs`}
+
+
+ setQuery(e.target.value)}
+ />
+
+
+
+
+
+ {error ? {error}
: null}
+
+ {loading ? : null}
+
+ {!loading && !error && filtered.length === 0 ? (
+ No MRs found.
+ ) : null}
+
+ {filtered.length > 0 ? (
+
+
+
+
+ | No |
+ Name |
+ Employee code |
+ Position name |
+ Contacts created |
+ Doctors with surveys |
+ Total surveys |
+ Doctors |
+
+
+
+ {filtered.map((row, index) => (
+
+ | {index + 1} |
+ {row.name || '-'} |
+ {row.employee_code || '-'} |
+ {row.position_name || '-'} |
+ {row.contacts_created} |
+ {row.doctors_with_surveys} |
+ {row.total_surveys} |
+
+ {row.contacts_created > 0 ? (
+
+ View Doctors
+
+ ) : (
+ -
+ )}
+ |
+
+ ))}
+
+
+
+ ) : null}
+
+ >
+ );
+}
diff --git a/frontend/src/components/exportCsv.js b/frontend/src/components/exportCsv.js
new file mode 100644
index 0000000..dca43c7
--- /dev/null
+++ b/frontend/src/components/exportCsv.js
@@ -0,0 +1,20 @@
+function csvCell(value) {
+ const text = value === null || value === undefined ? '' : String(value);
+ return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
+}
+
+/**
+ * Download rows as a CSV file. The UTF-8 BOM makes Excel read non-ASCII text correctly.
+ */
+export default function exportCsv(baseName, headers, rows) {
+ const lines = [headers, ...rows].map((row) => row.map(csvCell).join(','));
+ const blob = new Blob([`\uFEFF${lines.join('\r\n')}`], { type: 'text/csv;charset=utf-8;' });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = `${baseName}_${new Date().toISOString().slice(0, 10)}.csv`;
+ document.body.appendChild(link);
+ link.click();
+ link.remove();
+ URL.revokeObjectURL(url);
+}
diff --git a/frontend/src/components/useDashboardData.js b/frontend/src/components/useDashboardData.js
new file mode 100644
index 0000000..854f5a2
--- /dev/null
+++ b/frontend/src/components/useDashboardData.js
@@ -0,0 +1,45 @@
+import { useCallback, useEffect, useState } from 'react';
+
+/**
+ * Loads a dashboard endpoint, exposing data, loading/error state and a reload(refresh) function.
+ */
+export default function useDashboardData(fetcher, onUnauthorized, errorMessage) {
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState('');
+
+ const load = useCallback(async (refresh = false) => {
+ setLoading(true);
+ setError('');
+ try {
+ const res = await fetcher(refresh);
+ setData(res.data || null);
+ } catch (err) {
+ if (err.status === 401) {
+ onUnauthorized?.();
+ return;
+ }
+ setError(err.message || errorMessage);
+ } finally {
+ setLoading(false);
+ }
+ }, [fetcher, onUnauthorized, errorMessage]);
+
+ useEffect(() => {
+ load(false);
+ }, [load]);
+
+ return { data, loading, error, load };
+}
+
+export function formatDateTime(value) {
+ if (!value) return '-';
+ const date = new Date(value);
+ return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString();
+}
+
+export function matchesQuery(values, query) {
+ const q = query.trim().toLowerCase();
+ if (!q) return true;
+ return values.some((v) => String(v ?? '').toLowerCase().includes(q));
+}
diff --git a/frontend/src/pages/Dashboard.css b/frontend/src/pages/Dashboard.css
new file mode 100644
index 0000000..29f3e81
--- /dev/null
+++ b/frontend/src/pages/Dashboard.css
@@ -0,0 +1,365 @@
+.dash-page {
+ --dash-bg: #f5f6f8;
+ --dash-card: #ffffff;
+ --dash-text: #4c4c5c;
+ --dash-heading: #313a46;
+ --dash-muted: #8a969c;
+ --dash-border: #e7e9eb;
+ --dash-table-head: #f3f7f9;
+ --dash-row-hover: #f8f9fb;
+ --dash-info: #43bfe5;
+ --dash-success: #1abc9c;
+ --dash-warning: #f7b84b;
+ --dash-primary: #6658dd;
+ --dash-dark: #313a46;
+ --dash-radius: 0.3rem;
+ --dash-shadow: 0 1px 4px 0 rgba(130, 143, 163, 0.15);
+
+ display: flex;
+ min-height: 100vh;
+ background: var(--dash-bg);
+ color: var(--dash-text);
+ font-family: "Nunito", "Segoe UI", Arial, sans-serif;
+ font-size: 0.875rem;
+}
+
+.dash-topbar {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 1rem;
+ margin-bottom: 1.25rem;
+}
+
+.dash-title {
+ margin: 0;
+ font-size: 1.15rem;
+ font-weight: 700;
+ color: var(--dash-heading);
+}
+
+.dash-subtitle {
+ margin: 0.2rem 0 0;
+ color: var(--dash-muted);
+}
+
+.dash-sidebar {
+ position: sticky;
+ top: 0;
+ flex: 0 0 240px;
+ height: 100vh;
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ padding: 1.25rem 0.75rem;
+ overflow-y: auto;
+ background: var(--dash-card);
+ border-right: 1px solid var(--dash-border);
+ box-shadow: var(--dash-shadow);
+}
+
+.dash-sidebar-brand {
+ display: flex;
+ flex-direction: column;
+ padding: 0 0.85rem 1rem;
+ margin-bottom: 0.75rem;
+ border-bottom: 1px solid var(--dash-border);
+}
+
+.dash-sidebar-brand strong {
+ font-size: 1.35rem;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ color: var(--dash-heading);
+}
+
+.dash-main {
+ flex: 1 1 auto;
+ min-width: 0;
+ padding: 1.25rem 1.5rem 2.5rem;
+}
+
+.dash-nav-link {
+ display: block;
+ padding: 0.6rem 0.85rem;
+ border-radius: var(--dash-radius);
+ color: var(--dash-text);
+ font-weight: 600;
+ text-decoration: none;
+ transition: background 0.15s ease, color 0.15s ease;
+}
+
+.dash-nav-link:hover {
+ background: var(--dash-row-hover);
+ color: var(--dash-heading);
+}
+
+.dash-nav-link.active {
+ background: rgba(67, 191, 229, 0.15);
+ color: #1e9cc4;
+ box-shadow: inset 3px 0 0 var(--dash-info);
+}
+
+.dash-content {
+ min-width: 0;
+}
+
+@media (max-width: 800px) {
+ .dash-page {
+ flex-direction: column;
+ }
+
+ .dash-sidebar {
+ position: static;
+ height: auto;
+ flex: 0 0 auto;
+ flex-direction: row;
+ flex-wrap: wrap;
+ align-items: center;
+ padding: 0.75rem;
+ border-right: 0;
+ border-bottom: 1px solid var(--dash-border);
+ }
+
+ .dash-sidebar-brand {
+ width: 100%;
+ padding: 0 0.5rem 0.5rem;
+ margin-bottom: 0.25rem;
+ }
+
+ .dash-main {
+ padding: 1rem;
+ }
+}
+
+.dash-card {
+ background: var(--dash-card);
+ border-radius: var(--dash-radius);
+ box-shadow: var(--dash-shadow);
+ padding: 1.25rem;
+ margin-bottom: 1.25rem;
+}
+
+.dash-card-title {
+ margin: 0 0 1rem;
+ font-size: 0.95rem;
+ font-weight: 700;
+ color: var(--dash-heading);
+}
+
+.dash-center {
+ text-align: center;
+ padding: 1.5rem 0.5rem;
+}
+
+.dash-muted {
+ margin: 0;
+ color: var(--dash-muted);
+}
+
+.dash-footer-link {
+ margin-top: 1rem;
+}
+
+.dash-stats {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 1.25rem;
+ margin-bottom: 1.25rem;
+}
+
+@media (min-width: 900px) {
+ .dash-stats {
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ }
+}
+
+.dash-stat {
+ background: var(--dash-card);
+ border-radius: var(--dash-radius);
+ box-shadow: var(--dash-shadow);
+ border-left: 4px solid var(--dash-info);
+ padding: 1rem 1.25rem;
+}
+
+.dash-stat small {
+ display: block;
+ margin-bottom: 0.3rem;
+ color: var(--dash-muted);
+ font-weight: 600;
+}
+
+.dash-stat strong {
+ font-size: 1.5rem;
+ font-weight: 700;
+ color: var(--dash-heading);
+}
+
+.dash-stat.info { border-left-color: var(--dash-info); }
+.dash-stat.success { border-left-color: var(--dash-success); }
+.dash-stat.warning { border-left-color: var(--dash-warning); }
+.dash-stat.primary { border-left-color: var(--dash-primary); }
+
+.dash-toolbar {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ align-items: center;
+ gap: 0.75rem;
+ margin-bottom: 1rem;
+}
+
+.dash-toolbar-actions {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.dash-input {
+ min-width: 260px;
+ padding: 0.4rem 0.75rem;
+ border: 1px solid #ced4da;
+ border-radius: var(--dash-radius);
+ background: #fff;
+ color: var(--dash-text);
+ font: inherit;
+}
+
+.dash-input:focus {
+ outline: none;
+ border-color: var(--dash-info);
+ box-shadow: 0 0 0 0.15rem rgba(67, 191, 229, 0.25);
+}
+
+.dash-btn {
+ border: 0;
+ border-radius: var(--dash-radius);
+ padding: 0.4rem 0.85rem;
+ font: inherit;
+ font-size: 0.8rem;
+ font-weight: 600;
+ color: #fff;
+ cursor: pointer;
+ transition: filter 0.15s ease;
+}
+
+.dash-btn:hover:not(:disabled) {
+ filter: brightness(0.92);
+}
+
+.dash-btn:disabled {
+ opacity: 0.65;
+ cursor: not-allowed;
+}
+
+.dash-btn.info { background: var(--dash-info); }
+.dash-btn.dark { background: var(--dash-dark); }
+.dash-btn.success { background: var(--dash-success); }
+.dash-btn.warning { background: var(--dash-warning); color: var(--dash-heading); }
+
+.dash-table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+.dash-table th,
+.dash-table td {
+ padding: 0.85rem 0.75rem;
+ text-align: center;
+ border-bottom: 1px solid var(--dash-border);
+ vertical-align: middle;
+}
+
+.dash-table th,
+.dash-table td:not(.text-left) {
+ white-space: nowrap;
+}
+
+.dash-table th {
+ background: var(--dash-table-head);
+ color: var(--dash-heading);
+ font-weight: 700;
+ border-bottom: 0;
+}
+
+.dash-table tbody tr:hover {
+ background: var(--dash-row-hover);
+}
+
+.dash-table .text-left {
+ text-align: left;
+}
+
+.dash-table .dash-num {
+ font-weight: 700;
+ color: var(--dash-heading);
+}
+
+.dash-table-wide th.dash-question {
+ min-width: 180px;
+ white-space: normal;
+}
+
+.dash-table-wide td.dash-answer {
+ min-width: 180px;
+ white-space: normal;
+}
+
+@media (min-width: 900px) {
+ .dash-stats.dash-stats-3 {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ }
+}
+
+.dash-loading {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.75rem;
+ padding: 2.5rem 1rem;
+ font-size: 1rem;
+ font-weight: 700;
+ color: #1e9cc4;
+}
+
+.dash-spinner {
+ width: 26px;
+ height: 26px;
+ border: 3px solid rgba(67, 191, 229, 0.25);
+ border-top-color: var(--dash-info);
+ border-radius: 50%;
+ animation: dash-spin 0.8s linear infinite;
+}
+
+@keyframes dash-spin {
+ to { transform: rotate(360deg); }
+}
+
+.dash-btn-link {
+ display: inline-block;
+ text-decoration: none;
+ white-space: nowrap;
+}
+
+.dash-filter-bar {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ align-items: center;
+ gap: 0.75rem;
+ margin-bottom: 1.25rem;
+ padding: 0.75rem 1rem;
+ background: rgba(67, 191, 229, 0.12);
+ border-left: 3px solid var(--dash-info);
+ border-radius: var(--dash-radius);
+ color: var(--dash-heading);
+}
+
+.dash-pager {
+ display: flex;
+ justify-content: flex-end;
+ align-items: center;
+ gap: 0.75rem;
+ margin-top: 1rem;
+}
diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx
index a5fb38e..3fe6817 100644
--- a/frontend/src/pages/DashboardPage.jsx
+++ b/frontend/src/pages/DashboardPage.jsx
@@ -1,130 +1,69 @@
-import { useEffect, useState } from 'react';
-import { Link, Navigate, useNavigate } from 'react-router-dom';
-import { fetchDashboardStats, fetchDashboardSurveys } from '../api';
+import { useCallback } from 'react';
+import { Navigate, NavLink, Route, Routes, useNavigate } from 'react-router-dom';
import { clearSession, getUser, isLoggedIn } from '../auth';
+import TmUsersSection from '../components/TmUsersSection';
+import DoctorsSection from '../components/DoctorsSection';
+import PatientsSection from '../components/PatientsSection';
+import './Dashboard.css';
+
+const TABS = [
+ { to: '/dashboard', label: 'MR Dashboard', end: true },
+ { to: '/dashboard/doctors', label: 'Doctor-wise Dashboard' },
+ { to: '/dashboard/patients', label: 'Patient-wise Dashboard' },
+];
export default function DashboardPage() {
const navigate = useNavigate();
const loggedIn = isLoggedIn();
const user = getUser();
- const [stats, setStats] = useState(null);
- const [surveys, setSurveys] = useState([]);
- const [error, setError] = useState('');
- const [loading, setLoading] = useState(true);
- useEffect(() => {
- if (!loggedIn) return undefined;
- let cancelled = false;
- (async () => {
- setLoading(true);
- setError('');
- try {
- const [statsRes, surveysRes] = await Promise.all([
- fetchDashboardStats(),
- fetchDashboardSurveys(50, 0),
- ]);
- if (cancelled) return;
- setStats(statsRes.data);
- setSurveys(surveysRes.data?.items || []);
- } catch (err) {
- if (!cancelled) {
- if (err.status === 401) {
- clearSession();
- navigate('/login');
- return;
- }
- setError(err.message || 'Unable to load dashboard');
- }
- } finally {
- if (!cancelled) setLoading(false);
- }
- })();
- return () => {
- cancelled = true;
- };
- }, [loggedIn, navigate]);
+ const logout = useCallback(() => {
+ clearSession();
+ navigate('/login');
+ }, [navigate]);
if (!loggedIn) {
return ;
}
- const logout = () => {
- clearSession();
- navigate('/login');
- };
-
return (
-
-
-
-
TGICS Dashboard
-
{user?.email || 'Admin'}
+
+
- {loading ?
Loading…
: null}
- {error ?
{error}
: null}
+
+
+
+
TGICS (The Great Indian Cough Study - Dr Enrolment) Dashboard
+
{user?.email || 'Admin'}
+
+
+
- {!loading && stats ? (
-
-
- Total surveys
- {stats.total_surveys}
-
-
- With cough
- {stats.with_cough}
-
-
- Without cough
- {stats.without_cough}
-
-
- Doctors with surveys
- {stats.doctors_with_surveys}
-
-
- ) : null}
-
- {!loading ? (
-
- Recent surveys
- {surveys.length === 0 ? (
- No surveys submitted yet.
- ) : (
-
-
-
-
- | ID |
- Contact |
- User |
- Cough |
- Submitted |
-
-
-
- {surveys.map((row) => (
-
- | {row.id} |
- {row.contact_id} |
- {row.user_id} |
- {row.has_cough ? 'Yes' : 'No'} |
- {row.created_at ? new Date(row.created_at).toLocaleString() : '-'} |
-
- ))}
-
-
-
- )}
-
- Public home
-
-
- ) : null}
+
+
+ } />
+ } />
+ } />
+ } />
+
+
+
);
}