diff --git a/backend/.env.example b/backend/.env.example index cf36add..160670a 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -21,6 +21,9 @@ RAPPORT_API_BASE=https://rapportapi.click/ RAPPORT_ACCESS_TOKEN= RAPPORT_PARTITION=rapport-lifemankind +# Dashboard TM list: comma-separated Rapport role ids +TGICS_TM_ROLE_IDS=1655449171-a39a5017-8232-4d58-L1-90d06bc733d0 + # Dashboard auth JWT_SECRET= JWT_EXPIRES_IN=7d diff --git a/backend/nodemon.json b/backend/nodemon.json new file mode 100644 index 0000000..ed9277c --- /dev/null +++ b/backend/nodemon.json @@ -0,0 +1,4 @@ +{ + "watch": ["src", ".env"], + "ext": "js,json,env" +} diff --git a/backend/src/controllers/dashboardController.js b/backend/src/controllers/dashboardController.js index 9ee2e4e..4292ce9 100644 --- a/backend/src/controllers/dashboardController.js +++ b/backend/src/controllers/dashboardController.js @@ -1,4 +1,12 @@ const { pool } = require('../config/db'); +const { getUsersByRoles, getPositionsByOwner, getEnrolledContacts, getContactById, } = require('../services/rapport'); + +function tmRoleIds() { + return String(process.env.TGICS_TM_ROLE_IDS || '') + .split(',') + .map((r) => r.trim()) + .filter(Boolean); +} async function listSurveys(request, h) { try { @@ -52,4 +60,201 @@ async function surveyStats(request, h) { } } -module.exports = { listSurveys, surveyStats }; +async function getTmUsers(request, h) { + const roleIds = tmRoleIds(); + if (!roleIds.length) { + return h.response({ status: 'error', message: 'TGICS_TM_ROLE_IDS is not configured.' }).code(503); + } + + try { + const refresh = String(request.query.refresh || '') === '1'; + const [users, positions, enrolled, [surveyRows]] = await Promise.all([ + getUsersByRoles(roleIds, { refresh }), + getPositionsByOwner({ refresh }), + getEnrolledContacts({ refresh }), + pool.query( + `SELECT user_id, COUNT(*) AS total_surveys, COUNT(DISTINCT contact_id) AS doctors_with_surveys + FROM surveys + GROUP BY user_id` + ), + ]); + + const contactsByUser = new Map(); + for (const contact of enrolled) { + contactsByUser.set(contact.user_id, (contactsByUser.get(contact.user_id) || 0) + 1); + } + const surveysByUser = new Map( + surveyRows.map((row) => [String(row.user_id), { + total_surveys: Number(row.total_surveys) || 0, + doctors_with_surveys: Number(row.doctors_with_surveys) || 0, + }]) + ); + + const items = users + .map((user) => { + const position = positions.get(user.id) || {}; + const surveys = surveysByUser.get(user.id) || {}; + return { + user_id: user.id, + name: `${user.firstname || ''} ${user.lastname || ''}`.trim(), + employee_code: user.employee_code || '', + designation: position.designation || user.designation || '', + position_name: position.position_name || '', + position_code: position.position_code || '', + contacts_created: contactsByUser.get(user.id) || 0, + doctors_with_surveys: surveys.doctors_with_surveys || 0, + total_surveys: surveys.total_surveys || 0, + }; + }) + .sort((a, b) => + b.total_surveys - a.total_surveys + || b.contacts_created - a.contacts_created + || a.name.localeCompare(b.name) + ); + + const totals = items.reduce( + (acc, row) => { + acc.contacts_created += row.contacts_created; + acc.doctors_with_surveys += row.doctors_with_surveys; + acc.total_surveys += row.total_surveys; + return acc; + }, + { tms: items.length, contacts_created: 0, doctors_with_surveys: 0, total_surveys: 0 } + ); + + return h.response({ + status: 'success', + data: { role_ids: roleIds, items, total: items.length, totals }, + }).code(200); + } catch (err) { + console.error('getTmUsers error', err); + return h.response({ + status: 'error', + message: err.message || 'Unable to load TM users.', + }).code(err.statusCode || 500); + } +} + +function parseAnswers(raw) { + if (raw && typeof raw === 'object') return raw; + try { + const parsed = JSON.parse(raw || '{}'); + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch { + return {}; + } +} + +function mrDetails(userId, positions, fallback = {}) { + const owner = positions.get(userId) || {}; + return { + mr_name: owner.name || fallback.user_name || '', + employee_code: owner.employee_code || '', + position_name: owner.position_name || fallback.position_name || '', + }; +} + +async function getDoctors(request, h) { + try { + const refresh = String(request.query.refresh || '') === '1'; + const [contacts, positions, [surveyRows]] = await Promise.all([ + getEnrolledContacts({ refresh }), + getPositionsByOwner({ refresh }), + pool.query( + `SELECT contact_id, COUNT(*) AS total_surveys + FROM surveys + GROUP BY contact_id` + ), + ]); + + const surveysByContact = new Map(surveyRows.map((row) => [String(row.contact_id), row])); + + const items = contacts.map((contact) => { + const surveys = surveysByContact.get(contact.id); + return { + contact_id: contact.id, + name: contact.name || '', + msl_code: contact.msl_code || '', + phone: contact.phone || '', + email: contact.email || '', + user_id: contact.user_id || '', + ...mrDetails(contact.user_id, positions, contact), + total_surveys: surveys ? Number(surveys.total_surveys) || 0 : 0, + }; + }).sort((a, b) => b.total_surveys - a.total_surveys || a.name.localeCompare(b.name)); + + const totals = { + doctors: items.length, + doctors_with_surveys: items.filter((d) => d.total_surveys > 0).length, + total_surveys: items.reduce((sum, d) => sum + d.total_surveys, 0), + }; + + return h.response({ + status: 'success', + data: { items, totals }, + }).code(200); + } catch (err) { + console.error('getDoctors error', err); + return h.response({ + status: 'error', + message: err.message || 'Unable to load doctors.', + }).code(err.statusCode || 500); + } +} + +async function getPatients(request, h) { + try { + const refresh = String(request.query.refresh || '') === '1'; + const [[rows], contacts, positions] = await Promise.all([ + pool.query( + `SELECT id, contact_id, user_id, has_cough, answers_json, created_at + FROM surveys + ORDER BY created_at DESC, id DESC` + ), + getEnrolledContacts({ refresh }), + getPositionsByOwner({ refresh }), + ]); + + const contactsById = new Map(contacts.map((c) => [c.id, c])); + for (const id of new Set(rows.map((r) => String(r.contact_id)))) { + if (contactsById.has(id)) continue; + try { + const contact = await getContactById(id); + if (contact) contactsById.set(id, contact); + } catch (err) { + console.warn('getPatients contact lookup failed', id, err.message); + } + } + + const items = rows.map((row) => { + const contact = contactsById.get(String(row.contact_id)) || {}; + return { + id: row.id, + created_at: row.created_at, + contact_id: row.contact_id, + doctor_name: contact.name || '', + user_id: row.user_id, + ...mrDetails(String(row.user_id), positions, contact), + has_cough: Boolean(row.has_cough), + answers: parseAnswers(row.answers_json), + }; + }); + + const totals = { + total_surveys: items.length, + }; + + return h.response({ + status: 'success', + data: { items, totals }, + }).code(200); + } catch (err) { + console.error('getPatients error', err); + return h.response({ + status: 'error', + message: err.message || 'Unable to load patients.', + }).code(err.statusCode || 500); + } +} + +module.exports = { listSurveys, surveyStats, getTmUsers, getDoctors, getPatients }; diff --git a/backend/src/routes/dashboardRoutes.js b/backend/src/routes/dashboardRoutes.js index 728ef4f..a043864 100644 --- a/backend/src/routes/dashboardRoutes.js +++ b/backend/src/routes/dashboardRoutes.js @@ -1,21 +1,19 @@ -const { listSurveys, surveyStats } = require('../controllers/dashboardController'); +const { listSurveys, surveyStats, getTmUsers, getDoctors, getPatients, } = require('../controllers/dashboardController'); const { requireAuth } = require('../middleware/auth'); +const authed = (path, handler) => ({ + method: 'GET', + path, + options: { + pre: [{ method: requireAuth }], + }, + handler, +}); + module.exports = [ - { - method: 'GET', - path: '/api/dashboard/stats', - options: { - pre: [{ method: requireAuth }], - }, - handler: surveyStats, - }, - { - method: 'GET', - path: '/api/dashboard/surveys', - options: { - pre: [{ method: requireAuth }], - }, - handler: listSurveys, - }, + authed('/api/dashboard/stats', surveyStats), + authed('/api/dashboard/surveys', listSurveys), + authed('/api/dashboard/tm-users', getTmUsers), + authed('/api/dashboard/doctors', getDoctors), + authed('/api/dashboard/patients', getPatients), ]; diff --git a/backend/src/services/rapport.js b/backend/src/services/rapport.js index 1f1ddfd..b706e9a 100644 --- a/backend/src/services/rapport.js +++ b/backend/src/services/rapport.js @@ -172,7 +172,133 @@ async function resolveTgicsDoctorByPhone(userId, phone) { return { ok: true, doctor: toDoctorPayload(contact, userId), contact }; } +async function rapportGet(service, path, params = {}) { + if (!ACCESS_TOKEN) { + const err = new Error('RAPPORT_ACCESS_TOKEN is not configured'); + err.statusCode = 503; + throw err; + } + + const url = new URL(`${service}/${path}`, API_BASE); + url.searchParams.set('application', 'rapport'); + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === null) continue; + url.searchParams.set(key, typeof value === 'object' ? JSON.stringify(value) : String(value)); + } + + const response = await fetch(url.toString(), { + method: 'GET', + headers: { + Authorization: ACCESS_TOKEN, + Accept: 'application/json', + }, + }); + + const body = await response.json().catch(() => ({})); + const apiStatus = Number(body?.statusCode || response.status); + if (!response.ok || apiStatus >= 400) { + const err = new Error(body.message || `Rapport ${service}/${path} failed (${apiStatus})`); + err.statusCode = apiStatus === 401 ? 503 : 502; + throw err; + } + return body?.data || {}; +} + +const CACHE_TTL_MS = Number(process.env.RAPPORT_CACHE_TTL_MS || 10 * 60 * 1000); +const cache = new Map(); + +async function cached(key, loader, refresh = false) { + const hit = cache.get(key); + if (!refresh && hit && hit.expiresAt > Date.now()) { + return hit.value; + } + const value = await loader(); + cache.set(key, { value, expiresAt: Date.now() + CACHE_TTL_MS }); + return value; +} + +/** + * Active users holding any of the given role ids (ums/users/dropdown, de-duplicated by id). + */ +async function getUsersByRoles(roleIds, { refresh = false } = {}) { + const ids = [...new Set((roleIds || []).map((r) => String(r).trim()).filter(Boolean))]; + return cached(`users:${ids.join(',')}`, async () => { + const byId = new Map(); + for (const roleId of ids) { + const data = await rapportGet('ums', 'users/dropdown', { filter: { roles: roleId } }); + for (const user of Array.isArray(data.items) ? data.items : []) { + if (user?.id && !byId.has(user.id)) { + byId.set(user.id, user); + } + } + } + return [...byId.values()]; + }, refresh); +} + +/** + * Map of owner user id -> position details (ums/hierarchy/get_all with positions + export). + */ +async function getPositionsByOwner({ refresh = false } = {}) { + return cached('positions', async () => { + const data = await rapportGet('ums', 'hierarchy/get_all', { positions: 1, export: 1 }); + const rows = Array.isArray(data.hierarchys) ? data.hierarchys : Array.isArray(data.items) ? data.items : []; + const byOwner = new Map(); + for (const row of rows) { + const owner = row?.owner; + if (!owner || owner === 'vacant' || byOwner.has(owner)) continue; + const info = row.owner_info || {}; + byOwner.set(owner, { + position_id: row.id || '', + position_name: row.name || '', + position_code: row.code || '', + designation: row.designation || '', + name: `${info.firstname || ''} ${info.lastname || ''}`.trim(), + employee_code: info.employee_code || '', + }); + } + return byOwner; + }, refresh); +} + +const ENROLLED_VALUES = ['Yes', 'yes', 'YES']; +const ES_PAGE_SIZE = 1000; +const ES_MAX_WINDOW = 10000; + +/** + * All TGICS-enrolled contacts (full get_all items). + * The ES term filter is case-sensitive, so each spelling of "yes" is queried and merged. + */ +async function getEnrolledContacts({ refresh = false } = {}) { + return cached('enrolled-contacts', async () => { + const byId = new Map(); + for (const value of ENROLLED_VALUES) { + for (let start = 0; start < ES_MAX_WINDOW; start += ES_PAGE_SIZE) { + const data = await rapportGet('contact', 'contact/get_all', { + start, + length: ES_PAGE_SIZE, + keyword: '', + search: { attribute_tgics_enrolled_doctor: value }, + }); + const items = Array.isArray(data.items) ? data.items : []; + for (const item of items) { + if (item?.id && !byId.has(item.id)) { + byId.set(item.id, item); + } + } + const total = Number(data.count) || 0; + if (items.length < ES_PAGE_SIZE || start + ES_PAGE_SIZE >= total) break; + } + } + return [...byId.values()]; + }, refresh); +} + module.exports = { + rapportGet, + getUsersByRoles, + getPositionsByOwner, + getEnrolledContacts, getContactById, isTgicsEnrolled, toDoctorPayload, diff --git a/frontend/index.html b/frontend/index.html index b48d617..070790a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,6 +4,9 @@ + + + TGICS — The Great Indian Cough Study diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index a38fc7b..121a4dd 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -11,7 +11,7 @@ export default function App() { } /> } /> - } /> + } /> } /> } /> } /> diff --git a/frontend/src/api.js b/frontend/src/api.js index c1815f6..28ce958 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -81,3 +81,15 @@ export function fetchDashboardStats() { export function fetchDashboardSurveys(limit = 50, offset = 0) { return request(`/api/dashboard/surveys?limit=${limit}&offset=${offset}`); } + +export function fetchTmUsers(refresh = false) { + return request(`/api/dashboard/tm-users${refresh ? '?refresh=1' : ''}`); +} + +export function fetchDashboardDoctors(refresh = false) { + return request(`/api/dashboard/doctors${refresh ? '?refresh=1' : ''}`); +} + +export function fetchDashboardPatients(refresh = false) { + return request(`/api/dashboard/patients${refresh ? '?refresh=1' : ''}`); +} diff --git a/frontend/src/components/DashboardLoading.jsx b/frontend/src/components/DashboardLoading.jsx new file mode 100644 index 0000000..153d79c --- /dev/null +++ b/frontend/src/components/DashboardLoading.jsx @@ -0,0 +1,8 @@ +export default function DashboardLoading() { + return ( +
+ + Loading data… +
+ ); +} diff --git a/frontend/src/components/DoctorsSection.jsx b/frontend/src/components/DoctorsSection.jsx new file mode 100644 index 0000000..a44e34a --- /dev/null +++ b/frontend/src/components/DoctorsSection.jsx @@ -0,0 +1,161 @@ +import { useMemo, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { fetchDashboardDoctors } from '../api'; +import DashboardLoading from './DashboardLoading'; +import exportCsv from './exportCsv'; +import useDashboardData, { matchesQuery } from './useDashboardData'; + +export default function DoctorsSection({ onUnauthorized }) { + const { data, loading, error, load } = useDashboardData( + fetchDashboardDoctors, + onUnauthorized, + 'Unable to load doctors.' + ); + const [query, setQuery] = useState(''); + const [searchParams] = useSearchParams(); + const mrId = searchParams.get('mr') || ''; + const mrName = searchParams.get('mr_name') || ''; + const mrCode = searchParams.get('mr_code') || ''; + + const items = useMemo(() => { + const all = data?.items || []; + return mrId ? all.filter((row) => row.user_id === mrId) : all; + }, [data, mrId]); + + const totals = useMemo(() => ({ + doctors: items.length, + doctors_with_surveys: items.filter((row) => row.total_surveys > 0).length, + total_surveys: items.reduce((sum, row) => sum + row.total_surveys, 0), + }), [items]); + + const filtered = useMemo( + () => items.filter((row) => + matchesQuery( + [row.name, row.msl_code, row.phone, row.mr_name, row.employee_code, row.position_name], + query + ) + ), + [items, query] + ); + + const value = (n) => (loading ? '…' : n ?? 0); + + const handleExport = () => { + const name = mrId ? `tgics_doctors_${mrCode || mrId}` : 'tgics_doctors'; + exportCsv( + name, + ['Doctor ID', 'Doctor name', 'Code', 'Phone', 'MR name', 'Employee code', 'Position name', 'Total surveys'], + filtered.map((row) => [ + row.contact_id, + row.name, + row.msl_code, + row.phone, + row.mr_name, + row.employee_code, + row.position_name, + row.total_surveys, + ]) + ); + }; + + return ( + <> + {mrId ? ( +
+ + Doctors of {mrName || 'selected MR'} + {mrCode ? ` (${mrCode})` : ''} + +
+ ) : null} + +
+
+ Total Doctors + {value(totals.doctors)} +
+
+ Total Doctors with Surveys + {value(totals.doctors_with_surveys)} +
+
+ Total Surveys + {value(totals.total_surveys)} +
+
+ +
+
+

+ {loading + ? '' + : query.trim() + ? `${filtered.length} of ${items.length} doctors` + : `${items.length} doctors`} +

+
+ setQuery(e.target.value)} + /> + + +
+
+ + {error ?

{error}

: null} + + {loading ? : null} + + {!loading && !error && filtered.length === 0 ? ( +

No doctors found.

+ ) : null} + + {filtered.length > 0 ? ( +
+ + + + + + + + + + + + + + + {filtered.map((row, index) => ( + + + + + + + + + + + ))} + +
NoDoctor nameCodePhoneMR nameEmployee codePosition nameTotal surveys
{index + 1}{row.name || '-'}{row.msl_code || '-'}{row.phone || '-'}{row.mr_name || '-'}{row.employee_code || '-'}{row.position_name || '-'}{row.total_surveys}
+
+ ) : null} +
+ + ); +} diff --git a/frontend/src/components/PatientsSection.jsx b/frontend/src/components/PatientsSection.jsx new file mode 100644 index 0000000..00ad5c7 --- /dev/null +++ b/frontend/src/components/PatientsSection.jsx @@ -0,0 +1,192 @@ +import { useMemo, useState } from 'react'; +import { fetchDashboardPatients } from '../api'; +import { QUESTIONS, STEPS } from '../questionnaire'; +import useDashboardData, { formatDateTime, matchesQuery } from './useDashboardData'; +import DashboardLoading from './DashboardLoading'; +import exportCsv from './exportCsv'; + +const PAGE_SIZE = 50; +const QUESTION_CODES = STEPS.flatMap((step) => step.fields); + +function answerText(question, answers) { + const raw = answers?.[question.code]; + if (raw === undefined || raw === null || raw === '' || (Array.isArray(raw) && raw.length === 0)) { + return '-'; + } + if (!question.options) return String(raw); + + const labelOf = (value) => { + if (value === 'others' && question.otherField) { + const other = String(answers[question.otherField] || '').trim(); + return other ? `Others: ${other}` : 'Others'; + } + return question.options.find((o) => o.value === value)?.label || value; + }; + return (Array.isArray(raw) ? raw : [raw]).map(labelOf).join(', '); +} + +export default function PatientsSection({ onUnauthorized }) { + const { data, loading, error, load } = useDashboardData( + fetchDashboardPatients, + onUnauthorized, + 'Unable to load patients.' + ); + const [query, setQuery] = useState(''); + const [page, setPage] = useState(1); + + const items = useMemo(() => data?.items || [], [data]); + const totals = data?.totals || {}; + + const filtered = useMemo( + () => items.filter((row) => + matchesQuery( + [row.doctor_name, row.mr_name, row.employee_code, row.position_name], + query + ) + ), + [items, query] + ); + + const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)); + const currentPage = Math.min(page, pageCount); + const pageRows = filtered.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE); + + const value = (n) => (loading ? '…' : n ?? 0); + + const handleExport = () => { + exportCsv( + 'tgics_patients', + [ + 'Patient ID', + 'Doctor name', + 'MR name', + 'Employee code', + 'Position name', + ...QUESTION_CODES.map((code) => QUESTIONS[code].label), + 'Submitted on', + ], + filtered.map((row) => [ + row.id, + row.doctor_name, + row.mr_name, + row.employee_code, + row.position_name, + ...QUESTION_CODES.map((code) => answerText(QUESTIONS[code], row.answers)), + formatDateTime(row.created_at), + ]) + ); + }; + + return ( + <> +
+
+ Total Surveys + {value(totals.total_surveys)} +
+
+ +
+
+

+ {loading + ? '' + : query.trim() + ? `${filtered.length} of ${items.length} patients` + : `${items.length} patients`} +

+
+ { + setQuery(e.target.value); + setPage(1); + }} + /> + + +
+
+ + {error ?

{error}

: null} + + {loading ? : null} + + {!loading && !error && filtered.length === 0 ? ( +

No patients found.

+ ) : null} + + {pageRows.length > 0 ? ( +
+ + + + + + + + + {QUESTION_CODES.map((code) => ( + + ))} + + + + + {pageRows.map((row, index) => ( + + + + + + + {QUESTION_CODES.map((code) => ( + + ))} + + + ))} + +
NoDoctor nameMR nameEmployee codePosition name{QUESTIONS[code].label}Submitted on
{(currentPage - 1) * PAGE_SIZE + index + 1}{row.doctor_name || '-'}{row.mr_name || '-'}{row.employee_code || '-'}{row.position_name || '-'} + {answerText(QUESTIONS[code], row.answers)} + {formatDateTime(row.created_at)}
+
+ ) : null} + + {pageCount > 1 ? ( +
+ + Page {currentPage} of {pageCount} + +
+ ) : null} +
+ + ); +} diff --git a/frontend/src/components/TmUsersSection.jsx b/frontend/src/components/TmUsersSection.jsx new file mode 100644 index 0000000..a4bb560 --- /dev/null +++ b/frontend/src/components/TmUsersSection.jsx @@ -0,0 +1,175 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { fetchTmUsers } from '../api'; +import DashboardLoading from './DashboardLoading'; +import exportCsv from './exportCsv'; + +const EMPTY_TOTALS = { tms: 0, contacts_created: 0, doctors_with_surveys: 0, total_surveys: 0 }; + +export default function TmUsersSection({ onUnauthorized }) { + const [items, setItems] = useState([]); + const [totals, setTotals] = useState(EMPTY_TOTALS); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [query, setQuery] = useState(''); + + const load = useCallback(async (refresh = false) => { + setLoading(true); + setError(''); + try { + const res = await fetchTmUsers(refresh); + setItems(Array.isArray(res.data?.items) ? res.data.items : []); + setTotals({ ...EMPTY_TOTALS, ...(res.data?.totals || {}) }); + } catch (err) { + if (err.status === 401) { + onUnauthorized?.(); + return; + } + setError(err.message || 'Unable to load Territory Managers.'); + } finally { + setLoading(false); + } + }, [onUnauthorized]); + + useEffect(() => { + load(false); + }, [load]); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return items; + return items.filter((row) => + [row.name, row.employee_code, row.position_name, row.position_code] + .some((v) => String(v || '').toLowerCase().includes(q)) + ); + }, [items, query]); + + const handleExport = () => { + exportCsv( + 'tgics_mr_dashboard', + ['MR ID', 'Name', 'Employee code', 'Position name', 'Contacts created', 'Doctors with surveys', 'Total surveys'], + filtered.map((row) => [ + row.user_id, + row.name, + row.employee_code, + row.position_name, + row.contacts_created, + row.doctors_with_surveys, + row.total_surveys, + ]) + ); + }; + + return ( + <> +
+
+ Total MR Count + {loading ? '…' : totals.tms} +
+
+ Total Contacts Created + {loading ? '…' : totals.contacts_created} +
+
+ Total Doctors with Surveys + {loading ? '…' : totals.doctors_with_surveys} +
+
+ Total Surveys + {loading ? '…' : totals.total_surveys} +
+
+ +
+
+

+ {loading + ? '' + : query.trim() + ? `${filtered.length} of ${items.length} MRs` + : `${items.length} MRs`} +

+
+ setQuery(e.target.value)} + /> + + +
+
+ + {error ?

{error}

: null} + + {loading ? : null} + + {!loading && !error && filtered.length === 0 ? ( +

No MRs found.

+ ) : null} + + {filtered.length > 0 ? ( +
+ + + + + + + + + + + + + + + {filtered.map((row, index) => ( + + + + + + + + + + + ))} + +
NoNameEmployee codePosition nameContacts createdDoctors with surveysTotal surveysDoctors
{index + 1}{row.name || '-'}{row.employee_code || '-'}{row.position_name || '-'}{row.contacts_created}{row.doctors_with_surveys}{row.total_surveys} + {row.contacts_created > 0 ? ( + + View Doctors + + ) : ( + - + )} +
+
+ ) : null} +
+ + ); +} diff --git a/frontend/src/components/exportCsv.js b/frontend/src/components/exportCsv.js new file mode 100644 index 0000000..dca43c7 --- /dev/null +++ b/frontend/src/components/exportCsv.js @@ -0,0 +1,20 @@ +function csvCell(value) { + const text = value === null || value === undefined ? '' : String(value); + return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; +} + +/** + * Download rows as a CSV file. The UTF-8 BOM makes Excel read non-ASCII text correctly. + */ +export default function exportCsv(baseName, headers, rows) { + const lines = [headers, ...rows].map((row) => row.map(csvCell).join(',')); + const blob = new Blob([`\uFEFF${lines.join('\r\n')}`], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `${baseName}_${new Date().toISOString().slice(0, 10)}.csv`; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); +} diff --git a/frontend/src/components/useDashboardData.js b/frontend/src/components/useDashboardData.js new file mode 100644 index 0000000..854f5a2 --- /dev/null +++ b/frontend/src/components/useDashboardData.js @@ -0,0 +1,45 @@ +import { useCallback, useEffect, useState } from 'react'; + +/** + * Loads a dashboard endpoint, exposing data, loading/error state and a reload(refresh) function. + */ +export default function useDashboardData(fetcher, onUnauthorized, errorMessage) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + const load = useCallback(async (refresh = false) => { + setLoading(true); + setError(''); + try { + const res = await fetcher(refresh); + setData(res.data || null); + } catch (err) { + if (err.status === 401) { + onUnauthorized?.(); + return; + } + setError(err.message || errorMessage); + } finally { + setLoading(false); + } + }, [fetcher, onUnauthorized, errorMessage]); + + useEffect(() => { + load(false); + }, [load]); + + return { data, loading, error, load }; +} + +export function formatDateTime(value) { + if (!value) return '-'; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString(); +} + +export function matchesQuery(values, query) { + const q = query.trim().toLowerCase(); + if (!q) return true; + return values.some((v) => String(v ?? '').toLowerCase().includes(q)); +} diff --git a/frontend/src/pages/Dashboard.css b/frontend/src/pages/Dashboard.css new file mode 100644 index 0000000..29f3e81 --- /dev/null +++ b/frontend/src/pages/Dashboard.css @@ -0,0 +1,365 @@ +.dash-page { + --dash-bg: #f5f6f8; + --dash-card: #ffffff; + --dash-text: #4c4c5c; + --dash-heading: #313a46; + --dash-muted: #8a969c; + --dash-border: #e7e9eb; + --dash-table-head: #f3f7f9; + --dash-row-hover: #f8f9fb; + --dash-info: #43bfe5; + --dash-success: #1abc9c; + --dash-warning: #f7b84b; + --dash-primary: #6658dd; + --dash-dark: #313a46; + --dash-radius: 0.3rem; + --dash-shadow: 0 1px 4px 0 rgba(130, 143, 163, 0.15); + + display: flex; + min-height: 100vh; + background: var(--dash-bg); + color: var(--dash-text); + font-family: "Nunito", "Segoe UI", Arial, sans-serif; + font-size: 0.875rem; +} + +.dash-topbar { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; + margin-bottom: 1.25rem; +} + +.dash-title { + margin: 0; + font-size: 1.15rem; + font-weight: 700; + color: var(--dash-heading); +} + +.dash-subtitle { + margin: 0.2rem 0 0; + color: var(--dash-muted); +} + +.dash-sidebar { + position: sticky; + top: 0; + flex: 0 0 240px; + height: 100vh; + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 1.25rem 0.75rem; + overflow-y: auto; + background: var(--dash-card); + border-right: 1px solid var(--dash-border); + box-shadow: var(--dash-shadow); +} + +.dash-sidebar-brand { + display: flex; + flex-direction: column; + padding: 0 0.85rem 1rem; + margin-bottom: 0.75rem; + border-bottom: 1px solid var(--dash-border); +} + +.dash-sidebar-brand strong { + font-size: 1.35rem; + font-weight: 700; + letter-spacing: 0.04em; + color: var(--dash-heading); +} + +.dash-main { + flex: 1 1 auto; + min-width: 0; + padding: 1.25rem 1.5rem 2.5rem; +} + +.dash-nav-link { + display: block; + padding: 0.6rem 0.85rem; + border-radius: var(--dash-radius); + color: var(--dash-text); + font-weight: 600; + text-decoration: none; + transition: background 0.15s ease, color 0.15s ease; +} + +.dash-nav-link:hover { + background: var(--dash-row-hover); + color: var(--dash-heading); +} + +.dash-nav-link.active { + background: rgba(67, 191, 229, 0.15); + color: #1e9cc4; + box-shadow: inset 3px 0 0 var(--dash-info); +} + +.dash-content { + min-width: 0; +} + +@media (max-width: 800px) { + .dash-page { + flex-direction: column; + } + + .dash-sidebar { + position: static; + height: auto; + flex: 0 0 auto; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + padding: 0.75rem; + border-right: 0; + border-bottom: 1px solid var(--dash-border); + } + + .dash-sidebar-brand { + width: 100%; + padding: 0 0.5rem 0.5rem; + margin-bottom: 0.25rem; + } + + .dash-main { + padding: 1rem; + } +} + +.dash-card { + background: var(--dash-card); + border-radius: var(--dash-radius); + box-shadow: var(--dash-shadow); + padding: 1.25rem; + margin-bottom: 1.25rem; +} + +.dash-card-title { + margin: 0 0 1rem; + font-size: 0.95rem; + font-weight: 700; + color: var(--dash-heading); +} + +.dash-center { + text-align: center; + padding: 1.5rem 0.5rem; +} + +.dash-muted { + margin: 0; + color: var(--dash-muted); +} + +.dash-footer-link { + margin-top: 1rem; +} + +.dash-stats { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1.25rem; + margin-bottom: 1.25rem; +} + +@media (min-width: 900px) { + .dash-stats { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } +} + +.dash-stat { + background: var(--dash-card); + border-radius: var(--dash-radius); + box-shadow: var(--dash-shadow); + border-left: 4px solid var(--dash-info); + padding: 1rem 1.25rem; +} + +.dash-stat small { + display: block; + margin-bottom: 0.3rem; + color: var(--dash-muted); + font-weight: 600; +} + +.dash-stat strong { + font-size: 1.5rem; + font-weight: 700; + color: var(--dash-heading); +} + +.dash-stat.info { border-left-color: var(--dash-info); } +.dash-stat.success { border-left-color: var(--dash-success); } +.dash-stat.warning { border-left-color: var(--dash-warning); } +.dash-stat.primary { border-left-color: var(--dash-primary); } + +.dash-toolbar { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: 0.75rem; + margin-bottom: 1rem; +} + +.dash-toolbar-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; +} + +.dash-input { + min-width: 260px; + padding: 0.4rem 0.75rem; + border: 1px solid #ced4da; + border-radius: var(--dash-radius); + background: #fff; + color: var(--dash-text); + font: inherit; +} + +.dash-input:focus { + outline: none; + border-color: var(--dash-info); + box-shadow: 0 0 0 0.15rem rgba(67, 191, 229, 0.25); +} + +.dash-btn { + border: 0; + border-radius: var(--dash-radius); + padding: 0.4rem 0.85rem; + font: inherit; + font-size: 0.8rem; + font-weight: 600; + color: #fff; + cursor: pointer; + transition: filter 0.15s ease; +} + +.dash-btn:hover:not(:disabled) { + filter: brightness(0.92); +} + +.dash-btn:disabled { + opacity: 0.65; + cursor: not-allowed; +} + +.dash-btn.info { background: var(--dash-info); } +.dash-btn.dark { background: var(--dash-dark); } +.dash-btn.success { background: var(--dash-success); } +.dash-btn.warning { background: var(--dash-warning); color: var(--dash-heading); } + +.dash-table { + width: 100%; + border-collapse: collapse; +} + +.dash-table th, +.dash-table td { + padding: 0.85rem 0.75rem; + text-align: center; + border-bottom: 1px solid var(--dash-border); + vertical-align: middle; +} + +.dash-table th, +.dash-table td:not(.text-left) { + white-space: nowrap; +} + +.dash-table th { + background: var(--dash-table-head); + color: var(--dash-heading); + font-weight: 700; + border-bottom: 0; +} + +.dash-table tbody tr:hover { + background: var(--dash-row-hover); +} + +.dash-table .text-left { + text-align: left; +} + +.dash-table .dash-num { + font-weight: 700; + color: var(--dash-heading); +} + +.dash-table-wide th.dash-question { + min-width: 180px; + white-space: normal; +} + +.dash-table-wide td.dash-answer { + min-width: 180px; + white-space: normal; +} + +@media (min-width: 900px) { + .dash-stats.dash-stats-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +.dash-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + padding: 2.5rem 1rem; + font-size: 1rem; + font-weight: 700; + color: #1e9cc4; +} + +.dash-spinner { + width: 26px; + height: 26px; + border: 3px solid rgba(67, 191, 229, 0.25); + border-top-color: var(--dash-info); + border-radius: 50%; + animation: dash-spin 0.8s linear infinite; +} + +@keyframes dash-spin { + to { transform: rotate(360deg); } +} + +.dash-btn-link { + display: inline-block; + text-decoration: none; + white-space: nowrap; +} + +.dash-filter-bar { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: 0.75rem; + margin-bottom: 1.25rem; + padding: 0.75rem 1rem; + background: rgba(67, 191, 229, 0.12); + border-left: 3px solid var(--dash-info); + border-radius: var(--dash-radius); + color: var(--dash-heading); +} + +.dash-pager { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 0.75rem; + margin-top: 1rem; +} diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx index a5fb38e..3fe6817 100644 --- a/frontend/src/pages/DashboardPage.jsx +++ b/frontend/src/pages/DashboardPage.jsx @@ -1,130 +1,69 @@ -import { useEffect, useState } from 'react'; -import { Link, Navigate, useNavigate } from 'react-router-dom'; -import { fetchDashboardStats, fetchDashboardSurveys } from '../api'; +import { useCallback } from 'react'; +import { Navigate, NavLink, Route, Routes, useNavigate } from 'react-router-dom'; import { clearSession, getUser, isLoggedIn } from '../auth'; +import TmUsersSection from '../components/TmUsersSection'; +import DoctorsSection from '../components/DoctorsSection'; +import PatientsSection from '../components/PatientsSection'; +import './Dashboard.css'; + +const TABS = [ + { to: '/dashboard', label: 'MR Dashboard', end: true }, + { to: '/dashboard/doctors', label: 'Doctor-wise Dashboard' }, + { to: '/dashboard/patients', label: 'Patient-wise Dashboard' }, +]; export default function DashboardPage() { const navigate = useNavigate(); const loggedIn = isLoggedIn(); const user = getUser(); - const [stats, setStats] = useState(null); - const [surveys, setSurveys] = useState([]); - const [error, setError] = useState(''); - const [loading, setLoading] = useState(true); - useEffect(() => { - if (!loggedIn) return undefined; - let cancelled = false; - (async () => { - setLoading(true); - setError(''); - try { - const [statsRes, surveysRes] = await Promise.all([ - fetchDashboardStats(), - fetchDashboardSurveys(50, 0), - ]); - if (cancelled) return; - setStats(statsRes.data); - setSurveys(surveysRes.data?.items || []); - } catch (err) { - if (!cancelled) { - if (err.status === 401) { - clearSession(); - navigate('/login'); - return; - } - setError(err.message || 'Unable to load dashboard'); - } - } finally { - if (!cancelled) setLoading(false); - } - })(); - return () => { - cancelled = true; - }; - }, [loggedIn, navigate]); + const logout = useCallback(() => { + clearSession(); + navigate('/login'); + }, [navigate]); if (!loggedIn) { return ; } - const logout = () => { - clearSession(); - navigate('/login'); - }; - return ( -
-
-
-

TGICS Dashboard

-

{user?.email || 'Admin'}

+
+
+ {TABS.map((tab) => ( + `dash-nav-link${isActive ? ' active' : ''}`} + > + {tab.label} + + ))} + - {loading ?
Loading…
: null} - {error ?

{error}

: null} +
+
+
+

TGICS (The Great Indian Cough Study - Dr Enrolment) Dashboard

+

{user?.email || 'Admin'}

+
+ +
- {!loading && stats ? ( -
-
- Total surveys - {stats.total_surveys} -
-
- With cough - {stats.with_cough} -
-
- Without cough - {stats.without_cough} -
-
- Doctors with surveys - {stats.doctors_with_surveys} -
-
- ) : null} - - {!loading ? ( -
-

Recent surveys

- {surveys.length === 0 ? ( -

No surveys submitted yet.

- ) : ( -
- - - - - - - - - - - - {surveys.map((row) => ( - - - - - - - - ))} - -
IDContactUserCoughSubmitted
{row.id}{row.contact_id}{row.user_id}{row.has_cough ? 'Yes' : 'No'}{row.created_at ? new Date(row.created_at).toLocaleString() : '-'}
-
- )} -

- Public home -

-
- ) : null} +
+ + } /> + } /> + } /> + } /> + +
+
); }