Files
triqura-ecd/docs/intent/intake-intent-proces/block-template-pattern.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

895 lines
27 KiB
Markdown

# 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<Data>;
// 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<void>;
// 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<string, boolean>;
// 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 (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-slate-400 mr-2" />
<span className="text-sm text-slate-500">{message}</span>
</div>
);
}
interface BlockErrorProps {
message: string;
onRetry?: () => void;
}
export function BlockError({ message, onRetry }: BlockErrorProps) {
return (
<div className="text-center py-8">
<AlertCircle className="h-8 w-8 text-red-500 mx-auto mb-2" />
<p className="text-sm text-red-700 mb-3">{message}</p>
{onRetry && (
<Button variant="outline" size="sm" onClick={onRetry}>
<RefreshCw className="h-4 w-4 mr-1.5" />
Opnieuw proberen
</Button>
)}
</div>
);
}
interface BlockEmptyProps {
icon: LucideIcon;
message: string;
action?: {
label: string;
onClick: () => void;
};
}
export function BlockEmpty({ icon: Icon, message, action }: BlockEmptyProps) {
return (
<div className="text-center py-8 text-slate-400">
<Icon className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">{message}</p>
{action && (
<Button variant="outline" size="sm" onClick={action.onClick} className="mt-3">
{action.label}
</Button>
)}
</div>
);
}
```
### 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 (
<section className="bg-white rounded-lg border border-slate-200 p-4">
<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>
{children}
</section>
);
}
```
### 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 (
<Wrapper
onClick={onClick}
className={cn(
'flex items-center justify-between p-3 rounded-lg bg-slate-50 border border-slate-200 w-full text-left',
onClick && 'hover:bg-slate-100 cursor-pointer transition-colors'
)}
>
<div>
<p className="text-sm font-medium text-slate-900">{title}</p>
{subtitle && (
<p className="text-xs text-slate-500 mt-0.5">{subtitle}</p>
)}
</div>
{badge && (
<span className={cn(
'px-2 py-0.5 rounded-full text-xs font-medium',
BADGE_STYLES[badge.variant]
)}>
{badge.label}
</span>
)}
</Wrapper>
);
}
```
### 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 (
<div className="flex items-center justify-between pt-4 border-t border-slate-200 mt-4">
{secondaryAction ? (
<Button variant="ghost" size="sm" onClick={secondaryAction.onClick}>
{secondaryAction.icon && <secondaryAction.icon className="h-4 w-4 mr-1.5" />}
{secondaryAction.label}
</Button>
) : <div />}
{primaryAction && (
<Button
size="sm"
onClick={primaryAction.onClick}
disabled={primaryAction.loading}
>
{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>
);
}
```
---
## 4. Custom Hook Pattern
### 4.1 useBlockData (Generic Data Fetching)
```typescript
// lib/cortex/hooks/use-block-data.ts
interface UseBlockDataOptions<T> {
endpoint: string;
params?: Record<string, string>;
enabled?: boolean;
onError?: (error: Error) => void;
}
interface UseBlockDataResult<T> {
data: T | null;
isLoading: boolean;
error: string | null;
refetch: () => Promise<void>;
}
export function useBlockData<T>({
endpoint,
params,
enabled = true,
onError,
}: 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 () => {
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 (
<BlockContainer title={config.title} size={config.size}>
<BlockEmpty
icon={[Icon]}
message="Selecteer eerst een patiënt"
/>
</BlockContainer>
);
}
// Handle loading
if (isLoading) {
return (
<BlockContainer title={config.title} size={config.size}>
<BlockLoading message="[Name] laden..." />
</BlockContainer>
);
}
// Handle error
if (error) {
return (
<BlockContainer title={config.title} size={config.size}>
<BlockError message={error} onRetry={refetch} />
</BlockContainer>
);
}
// Handle empty
if (!data?.items?.length) {
return (
<BlockContainer title={config.title} size={config.size}>
<BlockEmpty
icon={[Icon]}
message="Geen [items] gevonden"
action={{
label: 'Toevoegen in dossier',
onClick: () => {
// Navigate to EPD
window.location.href = `/epd/patients/${patientId}/intakes/${intakeId}/[tab]`;
},
}}
/>
</BlockContainer>
);
}
// Render data
return (
<BlockContainer title={config.title} size={config.size}>
<div className="space-y-4">
<BlockSection icon={[Icon]} iconColor="text-[color]-600" title="[Section Title]" count={data.items.length}>
<div className="space-y-2">
{data.items.map((item) => (
<BlockItem
key={item.id}
title={item.title}
subtitle={item.subtitle}
badge={{
label: item.status,
variant: getBadgeVariant(item.status),
}}
/>
))}
</div>
</BlockSection>
<BlockFooter
secondaryAction={{
label: 'Bekijk in dossier',
icon: ExternalLink,
onClick: () => {
window.location.href = `/epd/patients/${patientId}/intakes/${intakeId}/[tab]`;
},
}}
primaryAction={{
label: 'Toevoegen',
onClick: () => {
// Open action block or navigate
},
}}
/>
</div>
</BlockContainer>
);
}
// ============================================================================
// 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<IntakeStatusData>({
endpoint: `/api/cortex/intake/${intakeId}/status`,
enabled: Boolean(intakeId),
});
if (!hasContext) {
return (
<BlockContainer title="Intake Status" size="md">
<BlockEmpty
icon={ClipboardList}
message="Selecteer eerst een patiënt met een actieve intake"
/>
</BlockContainer>
);
}
if (isLoading) {
return (
<BlockContainer title="Intake Status" size="md">
<BlockLoading message="Status laden..." />
</BlockContainer>
);
}
if (error) {
return (
<BlockContainer title="Intake Status" size="md">
<BlockError message={error} onRetry={refetch} />
</BlockContainer>
);
}
if (!data) {
return (
<BlockContainer title="Intake Status" size="md">
<BlockEmpty
icon={ClipboardList}
message="Geen actieve intake gevonden"
/>
</BlockContainer>
);
}
const progress = Math.round((data.completedCount / data.totalCount) * 100);
const incompleteSections = data.sections.filter(s => !s.completed && s.required);
return (
<BlockContainer title={`Intake Status - ${data.intakeTitle}`} size="md">
<div className="space-y-4">
{/* Progress Bar */}
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-slate-600">Voortgang</span>
<span className="font-medium text-slate-900">
{data.completedCount}/{data.totalCount} ({progress}%)
</span>
</div>
<div className="h-2 bg-slate-100 rounded-full overflow-hidden">
<div
className={cn(
'h-full rounded-full transition-all',
progress === 100 ? 'bg-green-500' : 'bg-teal-500'
)}
style={{ width: `${progress}%` }}
/>
</div>
</div>
{/* Incomplete Items (priority) */}
{incompleteSections.length > 0 && (
<div className="p-3 bg-amber-50 border border-amber-200 rounded-lg">
<p className="text-sm font-medium text-amber-800 mb-2">
Nog te voltooien ({incompleteSections.length})
</p>
<div className="space-y-1">
{incompleteSections.map((section) => (
<button
key={section.key}
onClick={() => {
window.location.href = `/epd/patients/${patientId}/intakes/${intakeId}/${section.path}`;
}}
className="flex items-center justify-between w-full p-2 rounded hover:bg-amber-100 transition-colors text-left"
>
<span className="text-sm text-amber-900">{section.label}</span>
<ChevronRight className="h-4 w-4 text-amber-600" />
</button>
))}
</div>
</div>
)}
{/* All Sections */}
<div className="space-y-1">
{data.sections.map((section) => (
<button
key={section.key}
onClick={() => {
window.location.href = `/epd/patients/${patientId}/intakes/${intakeId}/${section.path}`;
}}
className={cn(
'flex items-center justify-between w-full p-3 rounded-lg border transition-colors text-left',
section.completed
? 'bg-green-50 border-green-200 hover:bg-green-100'
: 'bg-slate-50 border-slate-200 hover:bg-slate-100'
)}
>
<div className="flex items-center gap-3">
{section.completed ? (
<CheckCircle2 className="h-5 w-5 text-green-600" />
) : (
<Circle className="h-5 w-5 text-slate-400" />
)}
<span className={cn(
'text-sm',
section.completed ? 'text-green-900' : 'text-slate-700'
)}>
{section.label}
</span>
{section.required && !section.completed && (
<span className="text-xs px-1.5 py-0.5 bg-red-100 text-red-700 rounded">
Verplicht
</span>
)}
</div>
<ChevronRight className={cn(
'h-4 w-4',
section.completed ? 'text-green-600' : 'text-slate-400'
)} />
</button>
))}
</div>
{/* Complete Message */}
{progress === 100 && (
<div className="p-3 bg-green-50 border border-green-200 rounded-lg text-center">
<CheckCircle2 className="h-6 w-6 text-green-600 mx-auto mb-2" />
<p className="text-sm font-medium text-green-800">
Intake is compleet!
</p>
<p className="text-xs text-green-600 mt-1">
Je kunt de intake nu afsluiten
</p>
</div>
)}
</div>
</BlockContainer>
);
}
```
---
## 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`