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

27 KiB

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

// 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

// 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

// 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

// 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

// 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

// 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)

// 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>
  );
}
// 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)

// 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)

// 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

// 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

// 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