Files
triqura-ecd/docs/intent/intake-intent-proces/fo-to-cortex-shared-components.md
colinislit af88ac9446 docs: add architecture and intake intent documentation
- Add architecture overview, implementation plan, and intent overview
- Add intake intent process specs (gap analyse, bouwplan, testplan)
- Add swift architecture specs and visualization prompts
- Remove obsolete aispeedrun-manifesto template

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 19:26:14 +01:00

34 KiB

Functioneel & Technisch Ontwerp: Cortex Shared Components

Projectnaam: Cortex Intake Intent System - Shared Components Versie: v1.0 Datum: 03-02-2026 Auteur: Colin Lit (met AI-assistentie)


1. Doel en Context

1.1 Doel van dit document

Dit gecombineerde FO/TO beschrijft de shared components die worden hergebruikt door alle Cortex Blocks. Het document combineert:

  • Functioneel (FO): Wat de gebruiker ziet en ervaart
  • Technisch (TO): Hoe de componenten worden gebouwd

1.2 Relatie met andere documenten

Document Relatie
intake-process-intents.md Beschrijft de intents die deze components gebruiken
gap-analyse-intake-cortex.md Identificeert welke components ontbreken
block-template-pattern.md Beschrijft het patroon waarop deze components gebaseerd zijn

1.3 Scope

In scope:

  • 4 nieuwe shared components voor Cortex Blocks
  • 2 nieuwe custom hooks voor data fetching en context

Buiten scope:

  • Individuele intake blocks (apart document)
  • Bestaande components (patient-list-item.tsx, linked-evidence.tsx)

2. Overzicht Componenten

2.1 Component Hiërarchie

components/cortex/
├── blocks/
│   └── [intake-block].tsx          ← Gebruikt shared components
│
├── shared/
│   ├── patient-list-item.tsx       ✅ Bestaat
│   ├── linked-evidence.tsx         ✅ Bestaat
│   │
│   ├── block-states.tsx            🆕 NIEUW (dit document)
│   ├── block-section.tsx           🆕 NIEUW (dit document)
│   ├── block-item.tsx              🆕 NIEUW (dit document)
│   └── block-footer.tsx            🆕 NIEUW (dit document)

lib/cortex/hooks/
├── use-patient-search.ts           ✅ Bestaat
├── use-patient-selection.ts        ✅ Bestaat
│
├── use-block-data.ts               🆕 NIEUW (dit document)
└── use-intake-context.ts           🆕 NIEUW (dit document)

2.2 Component Overzicht

Component Type Functie Gebruikt door
BlockLoading State Laad-indicator tijdens data fetch Alle blocks
BlockError State Foutmelding met retry optie Alle blocks
BlockEmpty State Lege staat met actie Alle blocks
BlockSection Layout Sectie met icon en titel Query blocks
BlockItem Layout Lijst-item met badge Query blocks
BlockFooter Layout Footer met acties Alle blocks

3. User Stories

3.1 Algemene User Stories

ID Rol Doel / Actie Verwachte waarde Prioriteit
US-SC-01 Gebruiker Zien dat data wordt geladen Weet dat systeem bezig is Hoog
US-SC-02 Gebruiker Foutmelding zien bij problemen Kan retry doen of hulp zoeken Hoog
US-SC-03 Gebruiker Lege staat zien met suggestie Weet wat te doen als geen data Middel
US-SC-04 Gebruiker Snel navigeren naar EPD Kan details bekijken in dossier Hoog
US-SC-05 Gebruiker Risico niveau direct zien Beoordeelt urgentie in één oogopslag Hoog

3.2 Developer User Stories

ID Rol Doel / Actie Verwachte waarde Prioriteit
US-DEV-01 Developer Consistente states hergebruiken Sneller nieuwe blocks bouwen Hoog
US-DEV-02 Developer Generieke data hook gebruiken Minder boilerplate code Hoog
US-DEV-03 Developer Intake context automatisch hebben Geen prop drilling nodig Middel

4. Functionele Specificaties per Component

4.1 BlockLoading

Doel: Toon visuele feedback tijdens het laden van data.

Functioneel gedrag:

  • Toont geanimeerde spinner (rotatie)
  • Toont optionele tekst (bijv. "Risico's laden...")
  • Centreert verticaal en horizontaal in block
  • Verdwijnt zodra data geladen is

Visueel:

┌─────────────────────────────────────┐
│                                     │
│                                     │
│         [◌ spinner]                 │
│         Risico's laden...           │
│                                     │
│                                     │
└─────────────────────────────────────┘

Props:

Prop Type Default Beschrijving
message string "Laden..." Tekst onder spinner

4.2 BlockError

Doel: Toon foutmelding met optie om opnieuw te proberen.

Functioneel gedrag:

  • Toont rood/oranje waarschuwingsicoon
  • Toont foutmelding (gebruikersvriendelijk, Nederlands)
  • Optionele "Opnieuw proberen" knop
  • Verdwijnt bij retry of bij sluiten block

Visueel:

┌─────────────────────────────────────┐
│                                     │
│            [⚠️ icon]                │
│                                     │
│   Kon risico's niet ophalen.        │
│   Controleer je internetverbinding. │
│                                     │
│      [🔄 Opnieuw proberen]          │
│                                     │
└─────────────────────────────────────┘

Props:

Prop Type Default Beschrijving
message string - Foutmelding tekst
onRetry () => void undefined Callback voor retry (toont knop indien aanwezig)

4.3 BlockEmpty

Doel: Toon informatieve lege staat met optionele actie.

Functioneel gedrag:

  • Toont context-relevant icoon (configureerbaar)
  • Toont melding waarom leeg
  • Optionele actie-knop (bijv. "Toevoegen in dossier")
  • Visueel subtiel (niet alarmerend)

Visueel:

┌─────────────────────────────────────┐
│                                     │
│         [📋 icon faded]             │
│                                     │
│     Geen risico's geregistreerd     │
│                                     │
│      [+ Toevoegen in dossier]       │
│                                     │
└─────────────────────────────────────┘

Props:

Prop Type Default Beschrijving
icon LucideIcon - Icoon component
message string - Hoofdboodschap
action { label: string; onClick: () => void } undefined Optionele actie knop

4.4 BlockSection

Doel: Groepeer gerelateerde content met visuele header.

Functioneel gedrag:

  • Toont icoon + titel + optionele count
  • Bevat children content
  • Consistente styling (witte achtergrond, border, padding)
  • Optioneel inklapbaar (v2)

Visueel:

┌─────────────────────────────────────┐
│ [⚠️] Risicotaxatie (3)              │  ← Header
├─────────────────────────────────────┤
│                                     │
│   [Content / children]              │  ← Children
│                                     │
└─────────────────────────────────────┘

Props:

Prop Type Default Beschrijving
icon LucideIcon - Icoon component
iconColor string "text-slate-600" Tailwind kleur class
title string - Sectie titel
count number undefined Optionele count badge
children ReactNode - Sectie inhoud

4.5 BlockItem

Doel: Toon een enkel item in een lijst met consistente styling.

Functioneel gedrag:

  • Toont titel + optionele subtitle
  • Toont status badge (kleur-gecodeerd)
  • Klikbaar indien onClick meegegeven
  • Hover state bij klikbaar
  • Geen hover bij niet-klikbaar

Badge varianten:

Variant Kleur Gebruik
default Grijs Neutraal, geen status
success Groen Laag risico, compleet
warning Oranje Gemiddeld risico, aandacht
danger Rood Hoog risico, urgent

Visueel:

┌─────────────────────────────────────────────────────┐
│ [Avatar?]  Suïcidaliteit                    [Matig] │  ← Klikbaar
│            12 jan 2026 • Dr. Jansen                 │
└─────────────────────────────────────────────────────┘

Props:

Prop Type Default Beschrijving
title string - Hoofdtekst
subtitle string undefined Subtekst (datum, auteur, etc.)
badge { label: string; variant: BadgeVariant } undefined Status badge
onClick () => void undefined Klik handler (maakt item klikbaar)

4.6 BlockFooter

Doel: Consistente footer met acties onderaan een block.

Functioneel gedrag:

  • Links: secundaire actie (ghost button)
  • Rechts: primaire actie (solid button)
  • Horizontale scheidingslijn boven footer
  • Loading state op primaire actie

Visueel:

─────────────────────────────────────────
[🔗 Bekijk in dossier]        [+ Toevoegen]
     (secundair)                (primair)

Props:

Prop Type Default Beschrijving
secondaryAction { label, icon?, onClick } undefined Linker actie
primaryAction { label, icon?, onClick, loading? } undefined Rechter actie

5. UI Overzicht

5.1 Component Compositie in een Block

┌─────────────────────────────────────────────────────────────┐
│ [Icon] Risicotaxatie                              [X Close] │  ← BlockContainer
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  ┌───────────────────────────────────────────────────────┐ │
│  │ [⚠️] Huidige risico's (3)                              │ │  ← BlockSection
│  ├───────────────────────────────────────────────────────┤ │
│  │                                                       │ │
│  │  ┌─────────────────────────────────────────────────┐ │ │
│  │  │ Suïcidaliteit                           [Matig] │ │ │  ← BlockItem
│  │  │ 12 jan 2026                                     │ │ │
│  │  └─────────────────────────────────────────────────┘ │ │
│  │                                                       │ │
│  │  ┌─────────────────────────────────────────────────┐ │ │
│  │  │ Agressie                                 [Laag] │ │ │  ← BlockItem
│  │  │ 10 jan 2026                                     │ │ │
│  │  └─────────────────────────────────────────────────┘ │ │
│  │                                                       │ │
│  └───────────────────────────────────────────────────────┘ │
│                                                             │
│  ─────────────────────────────────────────────────────────  │
│  [🔗 Bekijk in dossier]                      [+ Toevoegen]  │  ← BlockFooter
│                                                             │
└─────────────────────────────────────────────────────────────┘

5.2 State Flows

Block Lifecycle:

  ┌─────────┐    data?    ┌─────────┐
  │ Loading │───────────▶│  Data   │
  └─────────┘     yes     └─────────┘
       │                       │
       │ error                 │ empty
       ▼                       ▼
  ┌─────────┐            ┌─────────┐
  │  Error  │            │  Empty  │
  └─────────┘            └─────────┘
       │                       │
       │ retry                 │ action
       ▼                       ▼
  ┌─────────┐            ┌─────────┐
  │ Loading │            │   EPD   │
  └─────────┘            │ Navigatie│
                         └─────────┘

6. Technische Architectuur

6.1 Component Structuur

components/cortex/shared/
├── block-states.tsx        # BlockLoading, BlockError, BlockEmpty
├── block-section.tsx       # BlockSection
├── block-item.tsx          # BlockItem
├── block-footer.tsx        # BlockFooter
└── index.ts                # Re-exports

lib/cortex/hooks/
├── use-block-data.ts       # Generic data fetching hook
└── use-intake-context.ts   # Intake context hook

6.2 Dependencies

Externe dependencies:
├── lucide-react            # Icons
├── framer-motion           # Animaties (optioneel)
└── @/components/ui/button  # shadcn Button component

Interne dependencies:
├── @/lib/utils             # cn() helper
├── @/lib/cortex/error-handler  # safeFetch, getErrorInfo
├── @/stores/cortex-store   # useCortexStore
└── @/hooks/use-toast       # Toast notifications

7. Technische Specificaties

7.1 block-states.tsx

// components/cortex/shared/block-states.tsx

'use client';

import { Loader2, AlertCircle, RefreshCw } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';

// ============================================================================
// BlockLoading
// ============================================================================

interface BlockLoadingProps {
  /** Tekst onder de spinner */
  message?: string;
  /** Extra CSS classes */
  className?: string;
}

export function BlockLoading({
  message = 'Laden...',
  className
}: BlockLoadingProps) {
  return (
    <div className={cn(
      'flex flex-col items-center justify-center py-12',
      className
    )}>
      <Loader2 className="h-6 w-6 animate-spin text-slate-400 mb-2" />
      <span className="text-sm text-slate-500">{message}</span>
    </div>
  );
}

// ============================================================================
// BlockError
// ============================================================================

interface BlockErrorProps {
  /** Foutmelding tekst */
  message: string;
  /** Callback voor retry knop (toont knop indien aanwezig) */
  onRetry?: () => void;
  /** Extra CSS classes */
  className?: string;
}

export function BlockError({
  message,
  onRetry,
  className
}: BlockErrorProps) {
  return (
    <div className={cn(
      'flex flex-col items-center justify-center py-8 text-center',
      className
    )}>
      <AlertCircle className="h-8 w-8 text-red-500 mb-2" />
      <p className="text-sm text-red-700 mb-3 max-w-xs">{message}</p>
      {onRetry && (
        <Button variant="outline" size="sm" onClick={onRetry}>
          <RefreshCw className="h-4 w-4 mr-1.5" />
          Opnieuw proberen
        </Button>
      )}
    </div>
  );
}

// ============================================================================
// BlockEmpty
// ============================================================================

interface BlockEmptyProps {
  /** Icoon component */
  icon: LucideIcon;
  /** Hoofdboodschap */
  message: string;
  /** Optionele actie knop */
  action?: {
    label: string;
    onClick: () => void;
  };
  /** Extra CSS classes */
  className?: string;
}

export function BlockEmpty({
  icon: Icon,
  message,
  action,
  className
}: BlockEmptyProps) {
  return (
    <div className={cn(
      'flex flex-col items-center justify-center py-8 text-center',
      className
    )}>
      <Icon className="h-8 w-8 text-slate-300 mb-2" />
      <p className="text-sm text-slate-500 mb-3">{message}</p>
      {action && (
        <Button variant="outline" size="sm" onClick={action.onClick}>
          {action.label}
        </Button>
      )}
    </div>
  );
}

7.2 block-section.tsx

// components/cortex/shared/block-section.tsx

'use client';

import type { ReactNode } from 'react';
import type { LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';

interface BlockSectionProps {
  /** Icoon component */
  icon: LucideIcon;
  /** Tailwind kleur class voor icoon */
  iconColor?: string;
  /** Sectie titel */
  title: string;
  /** Optionele count badge */
  count?: number;
  /** Sectie inhoud */
  children: ReactNode;
  /** Extra CSS classes */
  className?: string;
}

export function BlockSection({
  icon: Icon,
  iconColor = 'text-slate-600',
  title,
  count,
  children,
  className,
}: BlockSectionProps) {
  return (
    <section className={cn(
      'bg-white rounded-lg border border-slate-200 p-4',
      className
    )}>
      {/* Header */}
      <div className="flex items-center gap-2 mb-3">
        <Icon className={cn('h-4 w-4', iconColor)} />
        <h3 className="text-sm font-medium text-slate-700">{title}</h3>
        {count !== undefined && (
          <span className="text-xs text-slate-500">({count})</span>
        )}
      </div>

      {/* Content */}
      {children}
    </section>
  );
}

7.3 block-item.tsx

// components/cortex/shared/block-item.tsx

'use client';

import { cn } from '@/lib/utils';

// ============================================================================
// Types
// ============================================================================

export type BadgeVariant = 'default' | 'success' | 'warning' | 'danger';

interface BlockItemProps {
  /** Hoofdtekst */
  title: string;
  /** Subtekst (datum, auteur, etc.) */
  subtitle?: string;
  /** Status badge */
  badge?: {
    label: string;
    variant: BadgeVariant;
  };
  /** Klik handler (maakt item klikbaar) */
  onClick?: () => void;
  /** Extra CSS classes */
  className?: string;
}

// ============================================================================
// Styling
// ============================================================================

const BADGE_STYLES: Record<BadgeVariant, string> = {
  default: 'bg-slate-100 text-slate-700 border-slate-200',
  success: 'bg-green-50 text-green-700 border-green-200',
  warning: 'bg-amber-50 text-amber-700 border-amber-200',
  danger: 'bg-red-50 text-red-700 border-red-200',
};

// ============================================================================
// Component
// ============================================================================

export function BlockItem({
  title,
  subtitle,
  badge,
  onClick,
  className
}: BlockItemProps) {
  const isClickable = Boolean(onClick);
  const Component = isClickable ? 'button' : 'div';

  return (
    <Component
      onClick={onClick}
      className={cn(
        'flex items-center justify-between p-3 rounded-lg',
        'bg-slate-50 border border-slate-200',
        'w-full text-left',
        isClickable && [
          'cursor-pointer',
          'hover:bg-slate-100 hover:border-slate-300',
          'transition-colors',
        ],
        className
      )}
    >
      {/* Content */}
      <div className="min-w-0 flex-1">
        <p className="text-sm font-medium text-slate-900 truncate">
          {title}
        </p>
        {subtitle && (
          <p className="text-xs text-slate-500 mt-0.5 truncate">
            {subtitle}
          </p>
        )}
      </div>

      {/* Badge */}
      {badge && (
        <span className={cn(
          'ml-3 flex-shrink-0',
          'px-2 py-0.5 rounded-full',
          'text-xs font-medium border',
          BADGE_STYLES[badge.variant]
        )}>
          {badge.label}
        </span>
      )}
    </Component>
  );
}

// ============================================================================
// Helper: Get variant from risk level
// ============================================================================

export function getRiskBadgeVariant(level: string): BadgeVariant {
  switch (level.toLowerCase()) {
    case 'laag':
      return 'success';
    case 'gemiddeld':
      return 'warning';
    case 'hoog':
    case 'zeer_hoog':
      return 'danger';
    default:
      return 'default';
  }
}

7.4 block-footer.tsx

// components/cortex/shared/block-footer.tsx

'use client';

import type { LucideIcon } from 'lucide-react';
import { Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';

interface ActionConfig {
  /** Knop label */
  label: string;
  /** Optioneel icoon */
  icon?: LucideIcon;
  /** Click handler */
  onClick: () => void;
  /** Loading state (alleen voor primary) */
  loading?: boolean;
  /** Disabled state */
  disabled?: boolean;
}

interface BlockFooterProps {
  /** Linker actie (ghost button) */
  secondaryAction?: ActionConfig;
  /** Rechter actie (solid button) */
  primaryAction?: ActionConfig;
  /** Extra CSS classes */
  className?: string;
}

export function BlockFooter({
  secondaryAction,
  primaryAction,
  className
}: BlockFooterProps) {
  // Don't render if no actions
  if (!secondaryAction && !primaryAction) {
    return null;
  }

  return (
    <div className={cn(
      'flex items-center justify-between',
      'pt-4 mt-4 border-t border-slate-200',
      className
    )}>
      {/* Secondary Action (left) */}
      {secondaryAction ? (
        <Button
          variant="ghost"
          size="sm"
          onClick={secondaryAction.onClick}
          disabled={secondaryAction.disabled}
        >
          {secondaryAction.icon && (
            <secondaryAction.icon className="h-4 w-4 mr-1.5" />
          )}
          {secondaryAction.label}
        </Button>
      ) : (
        <div /> // Spacer
      )}

      {/* Primary Action (right) */}
      {primaryAction && (
        <Button
          size="sm"
          onClick={primaryAction.onClick}
          disabled={primaryAction.loading || primaryAction.disabled}
        >
          {primaryAction.loading ? (
            <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />
          ) : primaryAction.icon ? (
            <primaryAction.icon className="h-4 w-4 mr-1.5" />
          ) : null}
          {primaryAction.label}
        </Button>
      )}
    </div>
  );
}

7.5 use-block-data.ts

// lib/cortex/hooks/use-block-data.ts

'use client';

import { useState, useEffect, useCallback } from 'react';
import { useToast } from '@/hooks/use-toast';
import { safeFetch, getErrorInfo } from '@/lib/cortex/error-handler';

// ============================================================================
// Types
// ============================================================================

interface UseBlockDataOptions<T> {
  /** API endpoint (relatief pad) */
  endpoint: string;
  /** Query parameters */
  params?: Record<string, string | undefined>;
  /** Of data moet worden opgehaald */
  enabled?: boolean;
  /** Callback bij error */
  onError?: (error: Error) => void;
  /** Operatie naam voor error messages */
  operationName?: string;
}

interface UseBlockDataResult<T> {
  /** Opgehaalde data */
  data: T | null;
  /** Loading state */
  isLoading: boolean;
  /** Error message */
  error: string | null;
  /** Refetch functie */
  refetch: () => Promise<void>;
}

// ============================================================================
// Hook
// ============================================================================

export function useBlockData<T>({
  endpoint,
  params,
  enabled = true,
  onError,
  operationName = 'Data laden',
}: UseBlockDataOptions<T>): UseBlockDataResult<T> {
  const [data, setData] = useState<T | null>(null);
  const [isLoading, setIsLoading] = useState(enabled);
  const [error, setError] = useState<string | null>(null);
  const { toast } = useToast();

  const fetchData = useCallback(async () => {
    if (!enabled) return;

    setIsLoading(true);
    setError(null);

    try {
      // Build URL with params
      const url = new URL(endpoint, window.location.origin);
      if (params) {
        Object.entries(params).forEach(([key, value]) => {
          if (value !== undefined) {
            url.searchParams.set(key, value);
          }
        });
      }

      const response = await safeFetch(
        url.toString(),
        undefined,
        { operation: operationName }
      );

      const result = await response.json();
      setData(result);
    } catch (err) {
      const errorInfo = getErrorInfo(err, { operation: operationName });
      setError(errorInfo.description);
      onError?.(err as Error);

      toast({
        variant: 'destructive',
        title: errorInfo.title,
        description: errorInfo.description,
      });
    } finally {
      setIsLoading(false);
    }
  }, [endpoint, JSON.stringify(params), enabled, onError, operationName, toast]);

  useEffect(() => {
    if (enabled) {
      fetchData();
    }
  }, [fetchData, enabled]);

  return { data, isLoading, error, refetch: fetchData };
}

7.6 use-intake-context.ts

// lib/cortex/hooks/use-intake-context.ts

'use client';

import { useMemo } from 'react';
import { useCortexStore } from '@/stores/cortex-store';
import type { BlockPrefillData } from '@/stores/cortex-store';
import { formatPatientName } from '@/lib/fhir/patient-mapper';

// ============================================================================
// Types
// ============================================================================

interface UseIntakeContextResult {
  /** Patient ID (van prefill of activePatient) */
  patientId: string | null;
  /** Intake ID (van prefill) */
  intakeId: string | null;
  /** Patient naam (voor display) */
  patientName: string | null;
  /** Of er voldoende context is */
  hasPatientContext: boolean;
  /** Of er intake context is */
  hasIntakeContext: boolean;
}

// ============================================================================
// Hook
// ============================================================================

export function useIntakeContext(
  prefill?: BlockPrefillData
): UseIntakeContextResult {
  const { activePatient } = useCortexStore();

  return useMemo(() => {
    // Patient context: van prefill of activePatient
    const patientId = prefill?.patientId || activePatient?.id || null;
    const patientName = prefill?.patientName ||
      (activePatient ? formatPatientName(activePatient) : null);

    // Intake context: alleen van prefill (TODO: activeIntake in store)
    const intakeId = prefill?.intakeId || null;

    return {
      patientId,
      intakeId,
      patientName,
      hasPatientContext: Boolean(patientId),
      hasIntakeContext: Boolean(intakeId),
    };
  }, [prefill, activePatient]);
}

7.7 Index Export

// components/cortex/shared/index.ts

// Block States
export { BlockLoading, BlockError, BlockEmpty } from './block-states';

// Block Layout
export { BlockSection } from './block-section';
export { BlockItem, getRiskBadgeVariant, type BadgeVariant } from './block-item';
export { BlockFooter } from './block-footer';

// Existing
export { PatientListItem, PatientListEmpty, PatientListLoading } from './patient-list-item';
export { LinkedEvidence } from './linked-evidence';

8. API Ontwerp

8.1 Geen nieuwe API endpoints

De shared components maken gebruik van bestaande endpoints en de nieuwe intake endpoints (beschreven in apart document). De hooks abstraheren de API calls.

8.2 Error Handling

Alle API errors worden afgehandeld via de bestaande error-handler.ts:

// Voorbeeld error flow
try {
  const response = await safeFetch('/api/cortex/intake/123/risks');
  // ...
} catch (err) {
  const errorInfo = getErrorInfo(err, { operation: 'Risico's laden' });
  // errorInfo.title = "Laden mislukt"
  // errorInfo.description = "Kon risico's niet ophalen. Probeer opnieuw."
}

9. Gebruikersrollen en Rechten

De shared components zijn UI-only en hebben geen eigen rechtenmodel. Rechten worden afgedwongen door:

  1. API routes - RLS policies in Supabase
  2. Store - activePatient alleen beschikbaar na selectie
  3. Blocks - Tonen alleen data waartoe gebruiker toegang heeft

10. Performance & Accessibility

10.1 Performance

Aspect Target Implementatie
Render tijd < 16ms Geen zware berekeningen in render
Bundle size < 5KB per component Tree-shakeable exports
Re-renders Minimaal useMemo voor derived state

10.2 Accessibility

Aspect Implementatie
Screen readers aria-label op interactieve elementen
Keyboard nav button elements met onClick
Focus visible Tailwind focus-visible:ring
Color contrast WCAG AA compliant kleuren
Loading state aria-busy="true"

11. Testing Strategie

11.1 Unit Tests

// __tests__/components/cortex/shared/block-item.test.tsx

describe('BlockItem', () => {
  it('renders title and subtitle', () => {
    render(<BlockItem title="Test" subtitle="Sub" />);
    expect(screen.getByText('Test')).toBeInTheDocument();
    expect(screen.getByText('Sub')).toBeInTheDocument();
  });

  it('renders badge with correct variant', () => {
    render(<BlockItem title="Test" badge={{ label: 'Hoog', variant: 'danger' }} />);
    const badge = screen.getByText('Hoog');
    expect(badge).toHaveClass('bg-red-50');
  });

  it('is clickable when onClick provided', () => {
    const onClick = jest.fn();
    render(<BlockItem title="Test" onClick={onClick} />);
    fireEvent.click(screen.getByRole('button'));
    expect(onClick).toHaveBeenCalled();
  });

  it('is not clickable when onClick not provided', () => {
    render(<BlockItem title="Test" />);
    expect(screen.queryByRole('button')).not.toBeInTheDocument();
  });
});

11.2 Visual Tests

Storybook stories voor elke component variant:

// stories/BlockItem.stories.tsx

export default {
  title: 'Cortex/Shared/BlockItem',
  component: BlockItem,
};

export const Default = () => <BlockItem title="Default item" />;
export const WithSubtitle = () => <BlockItem title="With subtitle" subtitle="Extra info" />;
export const WithBadge = () => <BlockItem title="With badge" badge={{ label: 'Hoog', variant: 'danger' }} />;
export const Clickable = () => <BlockItem title="Clickable" onClick={() => alert('Clicked!')} />;

12. Implementatie Volgorde

# Component Effort Dependencies
1 block-states.tsx S Geen
2 block-section.tsx S Geen
3 block-item.tsx S Geen
4 block-footer.tsx S @/components/ui/button
5 use-block-data.ts M error-handler.ts
6 use-intake-context.ts S cortex-store
7 index.ts exports S 1-6

Totaal geschat: ~1 dag


13. Risico's en Mitigatie

Risico Impact Mitigatie
Inconsistente styling Middel Design tokens in Tailwind config
Over-engineering Laag YAGNI - alleen bouwen wat nodig is
Bundle bloat Laag Tree-shakeable exports
Breaking changes Middel Semantic versioning, deprecation warnings

14. Bijlagen & Referenties

Projectdocumenten:

  • docs/intent/intake-intent-proces/intake-process-intents.md - Intent specificaties
  • docs/intent/intake-intent-proces/gap-analyse-intake-cortex.md - Gap analyse
  • docs/intent/intake-intent-proces/block-template-pattern.md - Template patterns

Bestaande code:

  • components/cortex/shared/patient-list-item.tsx - Referentie voor patterns
  • components/cortex/blocks/block-container.tsx - Parent container
  • lib/cortex/error-handler.ts - Error handling utilities

Tech documentatie: