Skip to content

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

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