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_ACCESS_TOKEN=
|
||||||
RAPPORT_PARTITION=rapport-lifemankind
|
RAPPORT_PARTITION=rapport-lifemankind
|
||||||
|
|
||||||
|
# Dashboard TM list: comma-separated Rapport role ids
|
||||||
|
TGICS_TM_ROLE_IDS=1655449171-a39a5017-8232-4d58-L1-90d06bc733d0
|
||||||
|
|
||||||
# Dashboard auth
|
# Dashboard auth
|
||||||
JWT_SECRET=
|
JWT_SECRET=
|
||||||
JWT_EXPIRES_IN=7d
|
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 { 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) {
|
async function listSurveys(request, h) {
|
||||||
try {
|
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 { requireAuth } = require('../middleware/auth');
|
||||||
|
|
||||||
|
const authed = (path, handler) => ({
|
||||||
|
method: 'GET',
|
||||||
|
path,
|
||||||
|
options: {
|
||||||
|
pre: [{ method: requireAuth }],
|
||||||
|
},
|
||||||
|
handler,
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = [
|
module.exports = [
|
||||||
{
|
authed('/api/dashboard/stats', surveyStats),
|
||||||
method: 'GET',
|
authed('/api/dashboard/surveys', listSurveys),
|
||||||
path: '/api/dashboard/stats',
|
authed('/api/dashboard/tm-users', getTmUsers),
|
||||||
options: {
|
authed('/api/dashboard/doctors', getDoctors),
|
||||||
pre: [{ method: requireAuth }],
|
authed('/api/dashboard/patients', getPatients),
|
||||||
},
|
|
||||||
handler: surveyStats,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
method: 'GET',
|
|
||||||
path: '/api/dashboard/surveys',
|
|
||||||
options: {
|
|
||||||
pre: [{ method: requireAuth }],
|
|
||||||
},
|
|
||||||
handler: listSurveys,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -172,7 +172,133 @@ async function resolveTgicsDoctorByPhone(userId, phone) {
|
||||||
return { ok: true, doctor: toDoctorPayload(contact, userId), contact };
|
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 = {
|
module.exports = {
|
||||||
|
rapportGet,
|
||||||
|
getUsersByRoles,
|
||||||
|
getPositionsByOwner,
|
||||||
|
getEnrolledContacts,
|
||||||
getContactById,
|
getContactById,
|
||||||
isTgicsEnrolled,
|
isTgicsEnrolled,
|
||||||
toDoctorPayload,
|
toDoctorPayload,
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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>
|
<title>TGICS — The Great Indian Cough Study</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ export default function App() {
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/dashboard" element={<DashboardPage />} />
|
<Route path="/dashboard/*" element={<DashboardPage />} />
|
||||||
<Route path="/:userId/:contactId" element={<SurveyPage />} />
|
<Route path="/:userId/:contactId" element={<SurveyPage />} />
|
||||||
<Route path="/:userId" element={<SurveyPage />} />
|
<Route path="/:userId" element={<SurveyPage />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
|
|
||||||
|
|
@ -81,3 +81,15 @@ export function fetchDashboardStats() {
|
||||||
export function fetchDashboardSurveys(limit = 50, offset = 0) {
|
export function fetchDashboardSurveys(limit = 50, offset = 0) {
|
||||||
return request(`/api/dashboard/surveys?limit=${limit}&offset=${offset}`);
|
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 { useCallback } from 'react';
|
||||||
import { Link, Navigate, useNavigate } from 'react-router-dom';
|
import { Navigate, NavLink, Route, Routes, useNavigate } from 'react-router-dom';
|
||||||
import { fetchDashboardStats, fetchDashboardSurveys } from '../api';
|
|
||||||
import { clearSession, getUser, isLoggedIn } from '../auth';
|
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() {
|
export default function DashboardPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const loggedIn = isLoggedIn();
|
const loggedIn = isLoggedIn();
|
||||||
const user = getUser();
|
const user = getUser();
|
||||||
const [stats, setStats] = useState(null);
|
|
||||||
const [surveys, setSurveys] = useState([]);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const logout = useCallback(() => {
|
||||||
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();
|
clearSession();
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
return;
|
}, [navigate]);
|
||||||
}
|
|
||||||
setError(err.message || 'Unable to load dashboard');
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (!cancelled) setLoading(false);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [loggedIn, navigate]);
|
|
||||||
|
|
||||||
if (!loggedIn) {
|
if (!loggedIn) {
|
||||||
return <Navigate to="/login" replace />;
|
return <Navigate to="/login" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const logout = () => {
|
|
||||||
clearSession();
|
|
||||||
navigate('/login');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page page-wide">
|
<div className="dash-page">
|
||||||
<header className="brand-bar dash-header">
|
<nav className="dash-sidebar">
|
||||||
<div>
|
<div className="dash-sidebar-brand">
|
||||||
<h1>TGICS Dashboard</h1>
|
<strong>TGICS</strong>
|
||||||
<p>{user?.email || 'Admin'}</p>
|
|
||||||
</div>
|
</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
|
Logout
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{loading ? <div className="card center">Loading…</div> : null}
|
<section className="dash-content">
|
||||||
{error ? <p className="error">{error}</p> : null}
|
<Routes>
|
||||||
|
<Route index element={<TmUsersSection onUnauthorized={logout} />} />
|
||||||
{!loading && stats ? (
|
<Route path="doctors" element={<DoctorsSection onUnauthorized={logout} />} />
|
||||||
<div className="stats-grid">
|
<Route path="patients" element={<PatientsSection onUnauthorized={logout} />} />
|
||||||
<div className="stat-card">
|
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||||
<small>Total surveys</small>
|
</Routes>
|
||||||
<strong>{stats.total_surveys}</strong>
|
</section>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue