# Cortex Block Template & Patterns **Datum:** 03-02-2026 **Status:** Ontwerp **Doel:** Herbruikbaar patroon voor alle intake-gerelateerde Cortex blocks --- ## 1. Anatomie van een Cortex Block ``` ┌─────────────────────────────────────────────────────────────┐ │ [Icon] Titel [Close] │ ← Header (via BlockContainer) ├─────────────────────────────────────────────────────────────┤ │ │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ Loading State │ │ ← State: Loading │ │ [Spinner] Data laden... │ │ │ └─────────────────────────────────────────────────────┘ │ │ │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ Error State │ │ ← State: Error │ │ [!] Foutmelding + [Retry] │ │ │ └─────────────────────────────────────────────────────┘ │ │ │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ Empty State │ │ ← State: Empty │ │ [Icon] Geen data + [Actie] │ │ │ └─────────────────────────────────────────────────────┘ │ │ │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ Data State │ │ ← State: Data │ │ │ │ │ │ Section 1: [Icon] Label │ │ │ │ ├── Item 1 │ │ │ │ └── Item 2 │ │ │ │ │ │ │ │ Section 2: [Icon] Label │ │ │ │ └── Content │ │ │ │ │ │ │ └─────────────────────────────────────────────────────┘ │ │ │ │ ───────────────────────────────────────────────────────── │ │ [Secondary Action] [Primary Action] │ ← Footer Actions │ │ └─────────────────────────────────────────────────────────────┘ ``` --- ## 2. Block Types ### 2.1 Query Block (Read-only) **Doel:** Data tonen, navigeren naar EPD voor bewerken ```typescript // Voorbeeld: RisicoQueryBlock // Input: "Wat zijn de risico's?" // Output: Lijst risico's met levels interface QueryBlockPattern { // Data ophalen fetch: () => Promise; // Tonen render: (data: Data) => JSX.Element; // Navigatie onViewInEPD: () => void; // "Bekijk in dossier" } ``` ### 2.2 Action Block (Create/Update) **Doel:** Snelle invoer, bevestiging, opslaan ```typescript // Voorbeeld: RisicoToevoegenBlock // Input: "Risico suïcidaliteit matig" // Output: Prefilled form, confirm, save interface ActionBlockPattern { // Prefill van intent entities prefill: ExtractedEntities; // Form state form: FormState; // Opslaan onSubmit: () => Promise; // Bevestiging confirmationRequired: boolean; } ``` ### 2.3 Status Block (Progress/Checklist) **Doel:** Voortgang tonen, navigeren naar onvolledige items ```typescript // Voorbeeld: IntakeStatusBlock // Input: "Wat moet ik nog doen?" // Output: Checklist met links interface StatusBlockPattern { // Status per onderdeel status: Record; // Navigatie naar onderdeel onNavigate: (section: string) => void; } ``` ### 2.4 Navigation Block (Route) **Doel:** Direct navigeren naar EPD pagina ```typescript // Voorbeeld: IntakeNavigatieBlock // Input: "Ga naar diagnose" // Output: Router.push() of openArtifact() interface NavigationBlockPattern { // Doel bepalen destination: string; // Navigeren onNavigate: () => void; } ``` --- ## 3. Shared Components ### 3.1 Block States ```typescript // components/cortex/shared/block-states.tsx interface BlockLoadingProps { message?: string; } export function BlockLoading({ message = 'Laden...' }: BlockLoadingProps) { return (
{message}
); } interface BlockErrorProps { message: string; onRetry?: () => void; } export function BlockError({ message, onRetry }: BlockErrorProps) { return (

{message}

{onRetry && ( )}
); } interface BlockEmptyProps { icon: LucideIcon; message: string; action?: { label: string; onClick: () => void; }; } export function BlockEmpty({ icon: Icon, message, action }: BlockEmptyProps) { return (

{message}

{action && ( )}
); } ``` ### 3.2 Block Section ```typescript // components/cortex/shared/block-section.tsx interface BlockSectionProps { icon: LucideIcon; iconColor?: string; title: string; count?: number; children: ReactNode; } export function BlockSection({ icon: Icon, iconColor = 'text-slate-600', title, count, children }: BlockSectionProps) { return (

{title}

{count !== undefined && ( ({count}) )}
{children}
); } ``` ### 3.3 Block Item (List Row) ```typescript // components/cortex/shared/block-item.tsx interface BlockItemProps { title: string; subtitle?: string; badge?: { label: string; variant: 'default' | 'success' | 'warning' | 'danger'; }; onClick?: () => void; } const BADGE_STYLES = { default: 'bg-slate-100 text-slate-700', success: 'bg-green-50 text-green-700', warning: 'bg-amber-50 text-amber-700', danger: 'bg-red-50 text-red-700', }; export function BlockItem({ title, subtitle, badge, onClick }: BlockItemProps) { const Wrapper = onClick ? 'button' : 'div'; return (

{title}

{subtitle && (

{subtitle}

)}
{badge && ( {badge.label} )}
); } ``` ### 3.4 Block Footer ```typescript // components/cortex/shared/block-footer.tsx interface BlockFooterProps { secondaryAction?: { label: string; icon?: LucideIcon; onClick: () => void; }; primaryAction?: { label: string; icon?: LucideIcon; onClick: () => void; loading?: boolean; }; } export function BlockFooter({ secondaryAction, primaryAction }: BlockFooterProps) { return (
{secondaryAction ? ( ) :
} {primaryAction && ( )}
); } ``` --- ## 4. Custom Hook Pattern ### 4.1 useBlockData (Generic Data Fetching) ```typescript // lib/cortex/hooks/use-block-data.ts interface UseBlockDataOptions { endpoint: string; params?: Record; enabled?: boolean; onError?: (error: Error) => void; } interface UseBlockDataResult { data: T | null; isLoading: boolean; error: string | null; refetch: () => Promise; } export function useBlockData({ endpoint, params, enabled = true, onError, }: UseBlockDataOptions): UseBlockDataResult { const [data, setData] = useState(null); const [isLoading, setIsLoading] = useState(enabled); const [error, setError] = useState(null); const { toast } = useToast(); const fetchData = useCallback(async () => { setIsLoading(true); setError(null); try { const url = new URL(endpoint, window.location.origin); if (params) { Object.entries(params).forEach(([key, value]) => { url.searchParams.set(key, value); }); } const response = await safeFetch(url.toString()); const result = await response.json(); setData(result); } catch (err) { const errorInfo = getErrorInfo(err); setError(errorInfo.description); onError?.(err as Error); toast({ variant: 'destructive', title: errorInfo.title, description: errorInfo.description, }); } finally { setIsLoading(false); } }, [endpoint, params, onError, toast]); useEffect(() => { if (enabled) { fetchData(); } }, [enabled, fetchData]); return { data, isLoading, error, refetch: fetchData }; } ``` ### 4.2 useIntakeContext (Intake-specific) ```typescript // lib/cortex/hooks/use-intake-context.ts interface UseIntakeContextResult { patientId: string | null; intakeId: string | null; patientName: string | null; hasContext: boolean; } export function useIntakeContext(prefill?: BlockPrefillData): UseIntakeContextResult { const { activePatient } = useCortexStore(); // TODO: Add activeIntake to cortex-store // For now, we need intakeId from prefill or URL const patientId = prefill?.patientId || activePatient?.id || null; const patientName = prefill?.patientName || (activePatient ? formatPatientName(activePatient) : null); const intakeId = prefill?.intakeId || null; return { patientId, intakeId, patientName, hasContext: Boolean(patientId), }; } ``` --- ## 5. Template: Query Block ```typescript // components/cortex/blocks/[name]-query-block.tsx 'use client'; /** * [Name] Query Block * * Block voor het tonen van [beschrijving]. * Intent: [intent_name] */ import { useEffect } from 'react'; import { useCortexStore } from '@/stores/cortex-store'; import { useToast } from '@/hooks/use-toast'; import { BlockContainer } from './block-container'; import { BlockLoading, BlockError, BlockEmpty } from '../shared/block-states'; import { BlockSection } from '../shared/block-section'; import { BlockItem } from '../shared/block-item'; import { BlockFooter } from '../shared/block-footer'; import { useBlockData } from '@/lib/cortex/hooks/use-block-data'; import { useIntakeContext } from '@/lib/cortex/hooks/use-intake-context'; import type { BlockPrefillData } from '@/stores/cortex-store'; import { BLOCK_CONFIGS } from '@/lib/cortex/types'; import { [Icon], ExternalLink } from 'lucide-react'; // ============================================================================ // Types // ============================================================================ interface [Name]QueryBlockProps { prefill?: BlockPrefillData; } interface [Name]Data { items: Array<{ id: string; // ... fields }>; } // ============================================================================ // Component // ============================================================================ export function [Name]QueryBlock({ prefill }: [Name]QueryBlockProps) { const config = BLOCK_CONFIGS['[block-type]']; const { closeBlock } = useCortexStore(); const { patientId, intakeId, patientName, hasContext } = useIntakeContext(prefill); // Fetch data const { data, isLoading, error, refetch } = useBlockData<[Name]Data>({ endpoint: `/api/cortex/intake/${intakeId}/[endpoint]`, enabled: Boolean(intakeId), }); // Handle no context if (!hasContext) { return ( ); } // Handle loading if (isLoading) { return ( ); } // Handle error if (error) { return ( ); } // Handle empty if (!data?.items?.length) { return ( { // Navigate to EPD window.location.href = `/epd/patients/${patientId}/intakes/${intakeId}/[tab]`; }, }} /> ); } // Render data return (
{data.items.map((item) => ( ))}
{ window.location.href = `/epd/patients/${patientId}/intakes/${intakeId}/[tab]`; }, }} primaryAction={{ label: 'Toevoegen', onClick: () => { // Open action block or navigate }, }} />
); } // ============================================================================ // Helpers // ============================================================================ function getBadgeVariant(status: string): 'default' | 'success' | 'warning' | 'danger' { switch (status) { case 'laag': return 'success'; case 'gemiddeld': return 'warning'; case 'hoog': case 'zeer_hoog': return 'danger'; default: return 'default'; } } ``` --- ## 6. Template: Status Block ```typescript // components/cortex/blocks/intake-status-block.tsx 'use client'; /** * Intake Status Block * * Block voor het tonen van intake voortgang/checklist. * Intent: intake_status */ import { useCortexStore } from '@/stores/cortex-store'; import { BlockContainer } from './block-container'; import { BlockLoading, BlockError, BlockEmpty } from '../shared/block-states'; import { useBlockData } from '@/lib/cortex/hooks/use-block-data'; import { useIntakeContext } from '@/lib/cortex/hooks/use-intake-context'; import type { BlockPrefillData } from '@/stores/cortex-store'; import { BLOCK_CONFIGS } from '@/lib/cortex/types'; import { CheckCircle2, Circle, ClipboardList, ChevronRight, } from 'lucide-react'; import { cn } from '@/lib/utils'; // ============================================================================ // Types // ============================================================================ interface IntakeStatusBlockProps { prefill?: BlockPrefillData; } interface IntakeStatusData { intakeId: string; intakeTitle: string; completedCount: number; totalCount: number; sections: Array<{ key: string; label: string; completed: boolean; required: boolean; path: string; }>; } // ============================================================================ // Constants // ============================================================================ const SECTION_ORDER = [ { key: 'algemeen', label: 'Algemeen', required: true }, { key: 'contactmomenten', label: 'Contactmomenten', required: false }, { key: 'kindcheck', label: 'Kindcheck', required: true }, { key: 'risicotaxatie', label: 'Risicotaxatie', required: true }, { key: 'anamnese', label: 'Anamnese', required: true }, { key: 'onderzoeken', label: 'Onderzoeken', required: false }, { key: 'rom', label: 'ROM', required: false }, { key: 'diagnose', label: 'Diagnose', required: true }, { key: 'behandeladvies', label: 'Behandeladvies', required: true }, ]; // ============================================================================ // Component // ============================================================================ export function IntakeStatusBlock({ prefill }: IntakeStatusBlockProps) { const config = BLOCK_CONFIGS['intake-status']; // Need to add this const { patientId, intakeId, hasContext } = useIntakeContext(prefill); const { data, isLoading, error, refetch } = useBlockData({ endpoint: `/api/cortex/intake/${intakeId}/status`, enabled: Boolean(intakeId), }); if (!hasContext) { return ( ); } if (isLoading) { return ( ); } if (error) { return ( ); } if (!data) { return ( ); } const progress = Math.round((data.completedCount / data.totalCount) * 100); const incompleteSections = data.sections.filter(s => !s.completed && s.required); return (
{/* Progress Bar */}
Voortgang {data.completedCount}/{data.totalCount} ({progress}%)
{/* Incomplete Items (priority) */} {incompleteSections.length > 0 && (

Nog te voltooien ({incompleteSections.length})

{incompleteSections.map((section) => ( ))}
)} {/* All Sections */}
{data.sections.map((section) => ( ))}
{/* Complete Message */} {progress === 100 && (

Intake is compleet!

Je kunt de intake nu afsluiten

)}
); } ``` --- ## 7. Checklist voor nieuwe Block Bij het bouwen van een nieuwe block: - [ ] **Types definiëren** - Props interface, Data interface - [ ] **Intent toevoegen** aan `lib/cortex/types.ts` - [ ] **Patterns toevoegen** aan `lib/cortex/reflex-classifier.ts` - [ ] **Block config toevoegen** aan `BLOCK_CONFIGS` - [ ] **API route maken** (indien nodig) in `app/api/cortex/` - [ ] **Block component bouwen** met shared components - [ ] **Canvas-area updaten** om block te renderen - [ ] **Testen** met voice input en prefill --- ## 8. Bestandsstructuur ``` components/cortex/ ├── blocks/ │ ├── block-container.tsx # Wrapper (bestaat) │ ├── dagnotitie-block.tsx # Bestaat │ ├── zoeken-block.tsx # Bestaat │ ├── overdracht-block.tsx # Bestaat │ ├── patient-dashboard-block.tsx # Bestaat │ │ │ ├── # NIEUW - Intake Blocks │ ├── intake-status-block.tsx # Checklist │ ├── risico-query-block.tsx # Risico's tonen │ ├── diagnose-query-block.tsx # Diagnoses tonen │ ├── kindcheck-query-block.tsx # Kindcheck status │ └── screening-query-block.tsx # Screening overzicht │ ├── shared/ │ ├── patient-list-item.tsx # Bestaat │ ├── linked-evidence.tsx # Bestaat │ │ │ ├── # NIEUW - Shared Block Components │ ├── block-states.tsx # Loading/Error/Empty │ ├── block-section.tsx # Section wrapper │ ├── block-item.tsx # List item │ └── block-footer.tsx # Footer actions │ lib/cortex/ ├── hooks/ │ ├── use-patient-search.ts # Bestaat │ ├── use-patient-selection.ts # Bestaat │ │ │ ├── # NIEUW │ ├── use-block-data.ts # Generic data fetching │ └── use-intake-context.ts # Intake context ``` --- ## 9. Volgende Stappen 1. **Shared components bouwen** (`block-states.tsx`, etc.) 2. **Hooks bouwen** (`use-block-data.ts`, `use-intake-context.ts`) 3. **Eerste block:** `IntakeStatusBlock` (meest waardevolle quick win) 4. **API route:** `/api/cortex/intake/[id]/status` 5. **Intent + patterns** toevoegen voor `intake_status`