Portal Architecture¶
The researcher portal is a React-based admin interface for managing studies, participants, and data.
Overview¶
- Framework: React 18
- State Management: React Query (TanStack Query)
- Routing: React Router v6
- Styling: Tailwind CSS 4 + CSS Modules
- Forms: React Hook Form + Zod validation
- Charts: Recharts
- Build Tool: Vite
Project Structure¶
portal/
├── src/
│ ├── main.tsx # Entry point with React Query provider
│ ├── App.tsx # Root component with routing
│ ├── pages/ # Page components
│ │ ├── Dashboard.tsx
│ │ ├── Studies/
│ │ │ ├── StudiesList.tsx
│ │ │ └── StudyDetail.tsx
│ │ ├── Participants/
│ │ │ ├── ParticipantsList.tsx
│ │ │ └── ParticipantDetail.tsx
│ │ ├── Batteries/
│ │ │ ├── BatteriesList.tsx
│ │ │ └── BatteryBuilder.tsx
│ │ ├── REDCapConfig.tsx
│ │ ├── Schedules.tsx
│ │ ├── Sessions.tsx
│ │ ├── Reports.tsx
│ │ └── Export.tsx
│ ├── components/
│ │ ├── layout/
│ │ │ ├── Sidebar.tsx
│ │ │ ├── SidebarSection.tsx
│ │ │ ├── SidebarLink.tsx
│ │ │ └── Layout.tsx
│ │ └── ui/
│ │ ├── StatusBadge.tsx
│ │ ├── StatCard.tsx
│ │ ├── PageHeader.tsx
│ │ ├── Modal.tsx
│ │ └── EmptyState.tsx
│ ├── contexts/
│ │ ├── AuthContext.tsx # Authentication state
│ │ └── StudyContext.tsx # Current study selection
│ ├── hooks/
│ │ └── useApi.ts # React Query hooks
│ ├── services/
│ │ └── api.ts # API client
│ ├── types/
│ │ └── index.ts # TypeScript interfaces
│ ├── validation/
│ │ └── schemas.ts # Zod validation schemas
│ └── styles/
│ ├── globals.css # Tailwind directives
│ └── main.css # CSS variables
├── tailwind.config.ts
├── vite.config.ts
└── tsconfig.json
Navigation Structure¶
Dark sidebar with grouped sections:
┌─────────────────────────┐
│ 🏠 Dashboard │
│ 📚 All Studies │
│ 📦 Module Library │
├─────────────────────────┤
│ 🔬 Study: NEUROCOG-01 ▼│
├─────────────────────────┤
│ Configure │
│ Overview │
│ Settings │
│ REDCap │
│ Batteries │
│ Schedule │
│ Consent │
├─────────────────────────┤
│ Operate │
│ Participants │
│ Visits │
│ Sessions │
├─────────────────────────┤
│ Analyze │
│ Reports │
│ Export │
└─────────────────────────┘
State Management¶
React Query¶
API data is managed with React Query:
// hooks/useApi.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '../services/api';
export function useStudies() {
return useQuery({
queryKey: ['studies'],
queryFn: () => api.getStudies(),
});
}
export function useCreateStudy() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (study: CreateStudyInput) => api.createStudy(study),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['studies'] });
},
});
}
Context for Global State¶
// contexts/StudyContext.tsx
import { createContext, useContext, useState, useEffect } from 'react';
interface StudyContextType {
currentStudyId: string | null;
setCurrentStudyId: (id: string | null) => void;
testMode: boolean;
setTestMode: (enabled: boolean) => void;
}
const StudyContext = createContext<StudyContextType>(null!);
export function StudyProvider({ children }: { children: React.ReactNode }) {
const [currentStudyId, setCurrentStudyId] = useState<string | null>(
() => localStorage.getItem('currentStudyId')
);
const [testMode, setTestMode] = useState(false);
useEffect(() => {
if (currentStudyId) {
localStorage.setItem('currentStudyId', currentStudyId);
} else {
localStorage.removeItem('currentStudyId');
}
}, [currentStudyId]);
return (
<StudyContext.Provider value={{ currentStudyId, setCurrentStudyId, testMode, setTestMode }}>
{children}
</StudyContext.Provider>
);
}
export const useStudy = () => useContext(StudyContext);
API Client¶
Typed API client with auth token injection:
// services/api.ts
class ApiClient {
private baseUrl: string;
constructor() {
this.baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000/api';
}
private getHeaders(): HeadersInit {
const token = localStorage.getItem('auth_token');
return {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` }),
};
}
async getStudies(): Promise<Study[]> {
const response = await fetch(`${this.baseUrl}/studies`, {
headers: this.getHeaders(),
});
if (!response.ok) throw new Error('Failed to fetch studies');
return response.json();
}
async createStudy(study: CreateStudyInput): Promise<Study> {
const response = await fetch(`${this.baseUrl}/studies`, {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify(study),
});
if (!response.ok) throw new Error('Failed to create study');
return response.json();
}
// ... more methods
}
export const api = new ApiClient();
UI Components¶
Layout Components¶
// components/layout/Sidebar.tsx
export function Sidebar() {
const { currentStudyId } = useStudy();
return (
<aside className="sidebar">
<SidebarSection title="Global">
<SidebarLink to="/" icon="🏠" label="Dashboard" />
<SidebarLink to="/studies" icon="📚" label="All Studies" />
<SidebarLink to="/modules" icon="📦" label="Module Library" />
</SidebarSection>
{currentStudyId && (
<>
<SidebarSection title="Configure">
<SidebarLink to={`/studies/${currentStudyId}`} label="Overview" />
<SidebarLink to={`/studies/${currentStudyId}/settings`} label="Settings" />
<SidebarLink to={`/studies/${currentStudyId}/redcap`} label="REDCap" />
<SidebarLink to={`/studies/${currentStudyId}/batteries`} label="Batteries" />
</SidebarSection>
<SidebarSection title="Operate">
<SidebarLink to={`/studies/${currentStudyId}/participants`} label="Participants" />
<SidebarLink to={`/studies/${currentStudyId}/visits`} label="Visits" />
<SidebarLink to={`/studies/${currentStudyId}/sessions`} label="Sessions" />
</SidebarSection>
</>
)}
</aside>
);
}
Reusable UI Components¶
// components/ui/StatCard.tsx
interface StatCardProps {
label: string;
value: number | string;
icon?: string;
change?: { value: string; direction: 'up' | 'down' };
}
export function StatCard({ label, value, icon, change }: StatCardProps) {
return (
<div className="stat-card">
{icon && <span className="stat-icon">{icon}</span>}
<div className="stat-value">{value}</div>
<div className="stat-label">{label}</div>
{change && (
<div className={`stat-change ${change.direction}`}>
{change.value}
</div>
)}
</div>
);
}
Form Validation¶
Using Zod for schema validation:
// validation/schemas.ts
import { z } from 'zod';
export const createStudySchema = z.object({
name: z.string().min(1, 'Study name is required'),
code: z.string().regex(/^[A-Z0-9-]+$/, 'Study code must be uppercase alphanumeric'),
description: z.string().optional(),
pi_name: z.string().min(1, 'Principal investigator is required'),
pi_email: z.string().email('Invalid email address'),
start_date: z.string().optional(),
end_date: z.string().optional(),
});
export type CreateStudyInput = z.infer<typeof createStudySchema>;
Usage with React Hook Form:
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { createStudySchema, CreateStudyInput } from '../validation/schemas';
function CreateStudyForm() {
const { register, handleSubmit, formState: { errors } } = useForm<CreateStudyInput>({
resolver: zodResolver(createStudySchema),
});
const createStudy = useCreateStudy();
const onSubmit = (data: CreateStudyInput) => {
createStudy.mutate(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('name')} />
{errors.name && <span>{errors.name.message}</span>}
<input {...register('code')} />
{errors.code && <span>{errors.code.message}</span>}
<button type="submit">Create Study</button>
</form>
);
}
Authentication¶
// contexts/AuthContext.tsx
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const token = localStorage.getItem('auth_token');
if (token) {
// Verify token and fetch user
api.getCurrentUser().then(setUser).catch(() => {
localStorage.removeItem('auth_token');
}).finally(() => setLoading(false));
} else {
setLoading(false);
}
}, []);
const login = async (email: string, password: string) => {
const { token, user } = await api.login(email, password);
localStorage.setItem('auth_token', token);
setUser(user);
};
const logout = () => {
localStorage.removeItem('auth_token');
setUser(null);
};
return (
<AuthContext.Provider value={{ user, login, logout, loading }}>
{children}
</AuthContext.Provider>
);
}
Routing¶
// App.tsx
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
function App() {
const { user, loading } = useAuth();
if (loading) return <LoadingScreen />;
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
{user ? (
<Route element={<Layout />}>
<Route index element={<Dashboard />} />
<Route path="studies" element={<StudiesList />} />
<Route path="studies/:id" element={<StudyDetail />} />
<Route path="studies/:id/participants" element={<ParticipantsList />} />
<Route path="studies/:id/participants/:pid" element={<ParticipantDetail />} />
<Route path="batteries" element={<BatteriesList />} />
<Route path="batteries/:id" element={<BatteryBuilder />} />
<Route path="redcap" element={<REDCapConfig />} />
<Route path="reports" element={<Reports />} />
<Route path="export" element={<Export />} />
</Route>
) : (
<Route path="*" element={<Navigate to="/login" replace />} />
)}
</Routes>
</BrowserRouter>
);
}
Styling¶
Tailwind CSS¶
Configuration in tailwind.config.ts:
import type { Config } from 'tailwindcss';
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
primary: '#2563eb',
accent: '#8b5cf6',
sidebar: {
bg: '#1e293b',
text: '#94a3b8',
active: '#ffffff',
hover: '#334155',
},
},
},
},
plugins: [],
} satisfies Config;
CSS Variables¶
/* styles/main.css */
:root {
--sidebar-bg: #1e293b;
--sidebar-text: #94a3b8;
--sidebar-text-active: #ffffff;
--sidebar-hover: #334155;
--sidebar-width: 260px;
--accent: #8b5cf6;
--accent-light: #a78bfa;
--accent-dark: #7c3aed;
--primary: #2563eb;
--success: #22c55e;
--warning: #f59e0b;
--danger: #ef4444;
--border: #e2e8f0;
}
Data Visualization¶
Using Recharts for analytics:
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend } from 'recharts';
function EnrollmentChart({ data }: { data: EnrollmentData[] }) {
return (
<LineChart width={600} height={300} data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="enrolled" stroke="#2563eb" />
<Line type="monotone" dataKey="screened" stroke="#8b5cf6" />
</LineChart>
);
}
Next Steps¶
- Architecture Overview - System design
- Client Architecture - Assessment interface
- Patient Portal Architecture - Patient interface
- Server Architecture - Backend API
- Portal Pages Reference - UI documentation