- 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>
1088 lines
34 KiB
Markdown
1088 lines
34 KiB
Markdown
# 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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`:
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// __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:
|
|
|
|
```typescript
|
|
// 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:**
|
|
- Lucide Icons: https://lucide.dev/icons
|
|
- Tailwind CSS: https://tailwindcss.com/docs
|
|
- shadcn/ui Button: https://ui.shadcn.com/docs/components/button
|