feat: Display biomarkers by category in collapsible sections on the dashboard, including new UI styles and backend category_id in the API response.
This commit is contained in:
@@ -43,6 +43,7 @@ pub struct ReferenceRuleResponse {
|
|||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct BiomarkerListItem {
|
pub struct BiomarkerListItem {
|
||||||
pub id: i32,
|
pub id: i32,
|
||||||
|
pub category_id: i32,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub test_category: String,
|
pub test_category: String,
|
||||||
pub unit: String,
|
pub unit: String,
|
||||||
@@ -62,6 +63,7 @@ pub async fn list_biomarkers(
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|b| BiomarkerListItem {
|
.map(|b| BiomarkerListItem {
|
||||||
id: b.id,
|
id: b.id,
|
||||||
|
category_id: b.category_id,
|
||||||
name: b.name,
|
name: b.name,
|
||||||
test_category: b.test_category,
|
test_category: b.test_category,
|
||||||
unit: b.unit,
|
unit: b.unit,
|
||||||
|
|||||||
@@ -386,4 +386,38 @@ select.input {
|
|||||||
|
|
||||||
.btn-danger:hover {
|
.btn-danger:hover {
|
||||||
background-color: color-mix(in srgb, var(--indicator-critical) 10%, transparent);
|
background-color: color-mix(in srgb, var(--indicator-critical) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Collapsible header */
|
||||||
|
.collapsible-header:hover {
|
||||||
|
background-color: var(--bg-secondary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Biomarker chips */
|
||||||
|
.biomarker-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
padding: var(--space-xs) var(--space-sm);
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: default;
|
||||||
|
transition: background-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.biomarker-chip:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--accent) 10%, var(--bg-secondary));
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.biomarker-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.biomarker-unit {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,21 @@ interface User {
|
|||||||
role: string
|
role: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Category {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Biomarker {
|
||||||
|
id: number
|
||||||
|
category_id: number
|
||||||
|
name: string
|
||||||
|
test_category: string
|
||||||
|
unit: string
|
||||||
|
methodology: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [user, setUser] = useState<User | null>(null)
|
const [user, setUser] = useState<User | null>(null)
|
||||||
@@ -15,6 +30,9 @@ export function Dashboard() {
|
|||||||
const [theme, setTheme] = useState(() =>
|
const [theme, setTheme] = useState(() =>
|
||||||
document.documentElement.getAttribute('data-theme') || 'light'
|
document.documentElement.getAttribute('data-theme') || 'light'
|
||||||
)
|
)
|
||||||
|
const [categories, setCategories] = useState<Category[]>([])
|
||||||
|
const [biomarkers, setBiomarkers] = useState<Biomarker[]>([])
|
||||||
|
const [expandedCategories, setExpandedCategories] = useState<Set<number>>(new Set())
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// First get auth info, then fetch full user profile for name
|
// First get auth info, then fetch full user profile for name
|
||||||
@@ -36,6 +54,15 @@ export function Dashboard() {
|
|||||||
})
|
})
|
||||||
.catch(() => navigate('/login'))
|
.catch(() => navigate('/login'))
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
|
|
||||||
|
// Fetch categories and biomarkers
|
||||||
|
Promise.all([
|
||||||
|
fetch('/api/categories', { credentials: 'include' }).then(r => r.json()),
|
||||||
|
fetch('/api/biomarkers', { credentials: 'include' }).then(r => r.json()),
|
||||||
|
]).then(([cats, bms]) => {
|
||||||
|
setCategories(cats)
|
||||||
|
setBiomarkers(bms)
|
||||||
|
})
|
||||||
}, [navigate])
|
}, [navigate])
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
@@ -53,6 +80,22 @@ export function Dashboard() {
|
|||||||
setTheme(newTheme)
|
setTheme(newTheme)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleCategory = (categoryId: number) => {
|
||||||
|
setExpandedCategories(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(categoryId)) {
|
||||||
|
next.delete(categoryId)
|
||||||
|
} else {
|
||||||
|
next.add(categoryId)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getBiomarkersForCategory = (categoryId: number) => {
|
||||||
|
return biomarkers.filter(b => b.category_id === categoryId)
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div style={{ padding: 'var(--space-lg)' }}>Loading...</div>
|
return <div style={{ padding: 'var(--space-lg)' }}>Loading...</div>
|
||||||
}
|
}
|
||||||
@@ -86,8 +129,8 @@ export function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
|
{/* User Welcome Card */}
|
||||||
<div className="card" style={{ marginBottom: 'var(--space-lg)' }}>
|
<div className="card" style={{ marginBottom: 'var(--space-lg)' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
<div>
|
<div>
|
||||||
@@ -102,6 +145,7 @@ export function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Admin Section */}
|
||||||
{user.role === 'admin' && (
|
{user.role === 'admin' && (
|
||||||
<div className="card" style={{ marginBottom: 'var(--space-lg)' }}>
|
<div className="card" style={{ marginBottom: 'var(--space-lg)' }}>
|
||||||
<h3 style={{ marginBottom: 'var(--space-sm)' }}>Admin</h3>
|
<h3 style={{ marginBottom: 'var(--space-sm)' }}>Admin</h3>
|
||||||
@@ -114,9 +158,70 @@ export function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="text-secondary text-sm">
|
{/* Biomarker Categories */}
|
||||||
Dashboard coming soon...
|
<h2 style={{ marginBottom: 'var(--space-md)' }}>Biomarkers</h2>
|
||||||
</p>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-sm)' }}>
|
||||||
|
{categories.map(category => {
|
||||||
|
const categoryBiomarkers = getBiomarkersForCategory(category.id)
|
||||||
|
const isExpanded = expandedCategories.has(category.id)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={category.id} className="card" style={{ padding: 0 }}>
|
||||||
|
{/* Category Header - Clickable */}
|
||||||
|
<button
|
||||||
|
className="collapsible-header"
|
||||||
|
onClick={() => toggleCategory(category.id)}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: 'var(--space-md)',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
textAlign: 'left',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span style={{ fontSize: '16px', fontWeight: 600 }}>{category.name}</span>
|
||||||
|
<span className="text-secondary text-sm" style={{ marginLeft: 'var(--space-sm)' }}>
|
||||||
|
({categoryBiomarkers.length} biomarkers)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: '18px', transition: 'transform 0.2s', transform: isExpanded ? 'rotate(180deg)' : 'rotate(0)' }}>
|
||||||
|
▼
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Biomarkers List - Collapsible */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div style={{ borderTop: '1px solid var(--border)', padding: 'var(--space-sm)' }}>
|
||||||
|
{categoryBiomarkers.length === 0 ? (
|
||||||
|
<p className="text-secondary text-sm" style={{ padding: 'var(--space-sm)' }}>
|
||||||
|
No biomarkers in this category
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--space-xs)' }}>
|
||||||
|
{categoryBiomarkers.map(biomarker => (
|
||||||
|
<div
|
||||||
|
key={biomarker.id}
|
||||||
|
className="biomarker-chip"
|
||||||
|
title={`${biomarker.name} (${biomarker.unit})`}
|
||||||
|
>
|
||||||
|
<span className="biomarker-name">{biomarker.name}</span>
|
||||||
|
<span className="biomarker-unit">{biomarker.unit}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user