Dashboard Changes

This commit is contained in:
Abhijit 2026-10-26 15:29:55 +05:30
parent 5f4ea78300
commit 8e8beaa70d
16 changed files with 1386 additions and 130 deletions

View File

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

4
backend/nodemon.json Normal file
View File

@ -0,0 +1,4 @@
{
"watch": ["src", ".env"],
"ext": "js,json,env"
}

View File

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

View File

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

View File

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

View File

@ -4,6 +4,9 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700&display=swap" rel="stylesheet" />
<title>TGICS — The Great Indian Cough Study</title>
</head>
<body>

View File

@ -11,7 +11,7 @@ export default function App() {
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/dashboard/*" element={<DashboardPage />} />
<Route path="/:userId/:contactId" element={<SurveyPage />} />
<Route path="/:userId" element={<SurveyPage />} />
<Route path="*" element={<Navigate to="/" replace />} />

View File

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

View File

@ -0,0 +1,8 @@
export default function DashboardLoading() {
return (
<div className="dash-loading" role="status">
<span className="dash-spinner" />
Loading data…
</div>
);
}

View File

@ -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 ? (
<div className="dash-filter-bar">
<span>
Doctors of <strong>{mrName || 'selected MR'}</strong>
{mrCode ? ` (${mrCode})` : ''}
</span>
</div>
) : null}
<div className="dash-stats dash-stats-3">
<div className="dash-stat primary">
<small>Total Doctors</small>
<strong>{value(totals.doctors)}</strong>
</div>
<div className="dash-stat warning">
<small>Total Doctors with Surveys</small>
<strong>{value(totals.doctors_with_surveys)}</strong>
</div>
<div className="dash-stat success">
<small>Total Surveys</small>
<strong>{value(totals.total_surveys)}</strong>
</div>
</div>
<main className="dash-card">
<div className="dash-toolbar">
<p className="dash-muted">
{loading
? ''
: query.trim()
? `${filtered.length} of ${items.length} doctors`
: `${items.length} doctors`}
</p>
<div className="dash-toolbar-actions">
<input
type="search"
className="dash-input"
placeholder="Search doctor, MR, employee code or position"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<button type="button" className="dash-btn info" onClick={() => load(true)} disabled={loading}>
Refresh Data
</button>
<button
type="button"
className="dash-btn dark"
onClick={handleExport}
disabled={loading || filtered.length === 0}
>
Export
</button>
</div>
</div>
{error ? <p className="error">{error}</p> : null}
{loading ? <DashboardLoading /> : null}
{!loading && !error && filtered.length === 0 ? (
<p className="dash-muted dash-center">No doctors found.</p>
) : null}
{filtered.length > 0 ? (
<div className="table-wrap">
<table className="dash-table">
<thead>
<tr>
<th>No</th>
<th className="text-left">Doctor name</th>
<th>Code</th>
<th>Phone</th>
<th className="text-left">MR name</th>
<th>Employee code</th>
<th>Position name</th>
<th>Total surveys</th>
</tr>
</thead>
<tbody>
{filtered.map((row, index) => (
<tr key={row.contact_id}>
<td>{index + 1}</td>
<td className="text-left">{row.name || '-'}</td>
<td>{row.msl_code || '-'}</td>
<td>{row.phone || '-'}</td>
<td className="text-left">{row.mr_name || '-'}</td>
<td>{row.employee_code || '-'}</td>
<td>{row.position_name || '-'}</td>
<td className="dash-num">{row.total_surveys}</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
</main>
</>
);
}

View File

@ -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 (
<>
<div className="dash-stats dash-stats-3">
<div className="dash-stat success">
<small>Total Surveys</small>
<strong>{value(totals.total_surveys)}</strong>
</div>
</div>
<main className="dash-card">
<div className="dash-toolbar">
<p className="dash-muted">
{loading
? ''
: query.trim()
? `${filtered.length} of ${items.length} patients`
: `${items.length} patients`}
</p>
<div className="dash-toolbar-actions">
<input
type="search"
className="dash-input"
placeholder="Search doctor, MR, employee code or position"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setPage(1);
}}
/>
<button type="button" className="dash-btn info" onClick={() => load(true)} disabled={loading}>
Refresh Data
</button>
<button
type="button"
className="dash-btn dark"
onClick={handleExport}
disabled={loading || filtered.length === 0}
>
Export
</button>
</div>
</div>
{error ? <p className="error">{error}</p> : null}
{loading ? <DashboardLoading /> : null}
{!loading && !error && filtered.length === 0 ? (
<p className="dash-muted dash-center">No patients found.</p>
) : null}
{pageRows.length > 0 ? (
<div className="table-wrap">
<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>
</tr>
</thead>
<tbody>
{pageRows.map((row, index) => (
<tr key={row.id}>
<td>{(currentPage - 1) * PAGE_SIZE + index + 1}</td>
<td className="text-left">{row.doctor_name || '-'}</td>
<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>
))}
<td>{formatDateTime(row.created_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
{pageCount > 1 ? (
<div className="dash-pager">
<button
type="button"
className="dash-btn dark"
onClick={() => setPage(currentPage - 1)}
disabled={currentPage <= 1}
>
Previous
</button>
<span className="dash-muted">Page {currentPage} of {pageCount}</span>
<button
type="button"
className="dash-btn dark"
onClick={() => setPage(currentPage + 1)}
disabled={currentPage >= pageCount}
>
Next
</button>
</div>
) : null}
</main>
</>
);
}

View File

@ -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 (
<>
<div className="dash-stats">
<div className="dash-stat primary">
<small>Total MR Count</small>
<strong>{loading ? '…' : totals.tms}</strong>
</div>
<div className="dash-stat info">
<small>Total Contacts Created</small>
<strong>{loading ? '…' : totals.contacts_created}</strong>
</div>
<div className="dash-stat warning">
<small>Total Doctors with Surveys</small>
<strong>{loading ? '…' : totals.doctors_with_surveys}</strong>
</div>
<div className="dash-stat success">
<small>Total Surveys</small>
<strong>{loading ? '…' : totals.total_surveys}</strong>
</div>
</div>
<main className="dash-card">
<div className="dash-toolbar">
<p className="dash-muted">
{loading
? ''
: query.trim()
? `${filtered.length} of ${items.length} MRs`
: `${items.length} MRs`}
</p>
<div className="dash-toolbar-actions">
<input
type="search"
className="dash-input"
placeholder="Search name, employee code or position"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<button type="button" className="dash-btn info" onClick={() => load(true)} disabled={loading}>
Refresh Data
</button>
<button
type="button"
className="dash-btn dark"
onClick={handleExport}
disabled={loading || filtered.length === 0}
>
Export
</button>
</div>
</div>
{error ? <p className="error">{error}</p> : null}
{loading ? <DashboardLoading /> : null}
{!loading && !error && filtered.length === 0 ? (
<p className="dash-muted dash-center">No MRs found.</p>
) : null}
{filtered.length > 0 ? (
<div className="table-wrap">
<table className="dash-table">
<thead>
<tr>
<th>No</th>
<th className="text-left">Name</th>
<th>Employee code</th>
<th>Position name</th>
<th>Contacts created</th>
<th>Doctors with surveys</th>
<th>Total surveys</th>
<th>Doctors</th>
</tr>
</thead>
<tbody>
{filtered.map((row, index) => (
<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">{row.total_surveys}</td>
<td>
{row.contacts_created > 0 ? (
<Link
className="dash-btn info dash-btn-link"
target="_blank"
rel="noopener noreferrer"
to={`/dashboard/doctors?${new URLSearchParams({
mr: row.user_id,
mr_name: row.name,
mr_code: row.employee_code,
})}`}
>
View Doctors
</Link>
) : (
<span className="dash-muted">-</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
</main>
</>
);
}

View File

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

View File

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

View File

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

View File

@ -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 <Navigate to="/login" replace />;
}
const logout = () => {
clearSession();
navigate('/login');
};
return (
<div className="page page-wide">
<header className="brand-bar dash-header">
<div>
<h1>TGICS Dashboard</h1>
<p>{user?.email || 'Admin'}</p>
<div className="dash-page">
<nav className="dash-sidebar">
<div className="dash-sidebar-brand">
<strong>TGICS</strong>
</div>
<button type="button" className="btn ghost" onClick={logout}>
Logout
</button>
</header>
{TABS.map((tab) => (
<NavLink
key={tab.to}
to={tab.to}
end={tab.end}
className={({ isActive }) => `dash-nav-link${isActive ? ' active' : ''}`}
>
{tab.label}
</NavLink>
))}
</nav>
{loading ? <div className="card center">Loading…</div> : null}
{error ? <p className="error">{error}</p> : null}
<div className="dash-main">
<header className="dash-topbar">
<div>
<h1 className="dash-title">TGICS (The Great Indian Cough Study - Dr Enrolment) Dashboard</h1>
<p className="dash-subtitle">{user?.email || 'Admin'}</p>
</div>
<button type="button" className="dash-btn dark" onClick={logout}>
Logout
</button>
</header>
{!loading && stats ? (
<div className="stats-grid">
<div className="stat-card">
<small>Total surveys</small>
<strong>{stats.total_surveys}</strong>
</div>
<div className="stat-card">
<small>With cough</small>
<strong>{stats.with_cough}</strong>
</div>
<div className="stat-card">
<small>Without cough</small>
<strong>{stats.without_cough}</strong>
</div>
<div className="stat-card">
<small>Doctors with surveys</small>
<strong>{stats.doctors_with_surveys}</strong>
</div>
</div>
) : null}
{!loading ? (
<main className="card">
<h2>Recent surveys</h2>
{surveys.length === 0 ? (
<p className="muted">No surveys submitted yet.</p>
) : (
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>ID</th>
<th>Contact</th>
<th>User</th>
<th>Cough</th>
<th>Submitted</th>
</tr>
</thead>
<tbody>
{surveys.map((row) => (
<tr key={row.id}>
<td>{row.id}</td>
<td className="mono">{row.contact_id}</td>
<td className="mono">{row.user_id}</td>
<td>{row.has_cough ? 'Yes' : 'No'}</td>
<td>{row.created_at ? new Date(row.created_at).toLocaleString() : '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<p className="muted small" style={{ marginTop: '1rem' }}>
<Link className="text-link" to="/">Public home</Link>
</p>
</main>
) : null}
<section className="dash-content">
<Routes>
<Route index element={<TmUsersSection onUnauthorized={logout} />} />
<Route path="doctors" element={<DoctorsSection onUnauthorized={logout} />} />
<Route path="patients" element={<PatientsSection onUnauthorized={logout} />} />
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
</section>
</div>
</div>
);
}