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)]
|
||||
pub struct BiomarkerListItem {
|
||||
pub id: i32,
|
||||
pub category_id: i32,
|
||||
pub name: String,
|
||||
pub test_category: String,
|
||||
pub unit: String,
|
||||
@@ -62,6 +63,7 @@ pub async fn list_biomarkers(
|
||||
.into_iter()
|
||||
.map(|b| BiomarkerListItem {
|
||||
id: b.id,
|
||||
category_id: b.category_id,
|
||||
name: b.name,
|
||||
test_category: b.test_category,
|
||||
unit: b.unit,
|
||||
|
||||
@@ -386,4 +386,38 @@ select.input {
|
||||
|
||||
.btn-danger:hover {
|
||||
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
|
||||
}
|
||||
|
||||
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() {
|
||||
const navigate = useNavigate()
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
@@ -15,6 +30,9 @@ export function Dashboard() {
|
||||
const [theme, setTheme] = useState(() =>
|
||||
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(() => {
|
||||
// First get auth info, then fetch full user profile for name
|
||||
@@ -36,6 +54,15 @@ export function Dashboard() {
|
||||
})
|
||||
.catch(() => navigate('/login'))
|
||||
.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])
|
||||
|
||||
const handleLogout = async () => {
|
||||
@@ -53,6 +80,22 @@ export function Dashboard() {
|
||||
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) {
|
||||
return <div style={{ padding: 'var(--space-lg)' }}>Loading...</div>
|
||||
}
|
||||
@@ -86,8 +129,8 @@ export function Dashboard() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
<main>
|
||||
{/* User Welcome Card */}
|
||||
<div className="card" style={{ marginBottom: 'var(--space-lg)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
@@ -102,6 +145,7 @@ export function Dashboard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Admin Section */}
|
||||
{user.role === 'admin' && (
|
||||
<div className="card" style={{ marginBottom: 'var(--space-lg)' }}>
|
||||
<h3 style={{ marginBottom: 'var(--space-sm)' }}>Admin</h3>
|
||||
@@ -114,9 +158,70 @@ export function Dashboard() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-secondary text-sm">
|
||||
Dashboard coming soon...
|
||||
</p>
|
||||
{/* Biomarker Categories */}
|
||||
<h2 style={{ marginBottom: 'var(--space-md)' }}>Biomarkers</h2>
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user