Dashboard Changes
This commit is contained in:
parent
5f4ea78300
commit
8e8beaa70d
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"watch": ["src", ".env"],
|
||||
"ext": "js,json,env"
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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' : ''}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
export default function DashboardLoading() {
|
||||
return (
|
||||
<div className="dash-loading" role="status">
|
||||
<span className="dash-spinner" />
|
||||
Loading data…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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) {
|
||||
const logout = useCallback(() => {
|
||||
clearSession();
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
setError(err.message || 'Unable to load dashboard');
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [loggedIn, navigate]);
|
||||
}, [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}>
|
||||
{TABS.map((tab) => (
|
||||
<NavLink
|
||||
key={tab.to}
|
||||
to={tab.to}
|
||||
end={tab.end}
|
||||
className={({ isActive }) => `dash-nav-link${isActive ? ' active' : ''}`}
|
||||
>
|
||||
{tab.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<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 ? <div className="card center">Loading…</div> : null}
|
||||
{error ? <p className="error">{error}</p> : null}
|
||||
|
||||
{!loading && stats ? (
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<small>Total surveys</small>
|
||||
<strong>{stats.total_surveys}</strong>
|
||||
<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 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}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue