feat(cortex): add Intake Blocks MVP for voice/text commands

Adds 4 new intents for intake workflow:
- intake_status: shows completion percentage and section checklist
- intake_navigeer: navigation to specific intake tabs
- risico_query: displays risk assessments with severity indicators
- diagnose_query: shows primary/secondary diagnoses

New components:
- IntakeStatusBlock, RisicoBlock, DiagnoseBlock
- Shared block components (BlockLoading, BlockError, BlockEmpty, BlockSection, BlockItem, BlockFooter)
- useBlockData and useIntakeContext hooks

New API routes:
- GET /api/cortex/intake/status
- GET /api/cortex/intake/risico
- GET /api/cortex/intake/diagnose

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
colinislit
2026-02-03 23:21:18 +01:00
parent 05836c5c6a
commit b095b1e492
22 changed files with 2415 additions and 15 deletions

View File

@@ -0,0 +1,303 @@
'use client';
/**
* DiagnoseBlock
*
* Shows diagnoses for an intake: primary/secondary distinction, ICD-10 codes.
* Uses Cortex shared components and hooks.
*
* Epic: E2.S3 - Intake Blocks
*/
import { useCortexStore } from '@/stores/cortex-store';
import { BlockContainer } from './block-container';
import { BLOCK_CONFIGS } from '@/lib/cortex/types';
import {
Stethoscope,
User,
ExternalLink,
Star,
Activity,
} from 'lucide-react';
// Use extracted hooks and components
import { useBlockData, useIntakeContext, type IntakePrefillData } from '@/lib/cortex/hooks';
import {
BlockLoading,
BlockError,
BlockEmpty,
BlockSection,
BlockItem,
BlockFooter,
type BadgeVariant,
} from '@/components/cortex/shared';
// Import response type from API
import type { IntakeDiagnoseResponse, DiagnosisItem } from '@/app/api/cortex/intake/diagnose/route';
// ============================================================================
// Types
// ============================================================================
interface DiagnoseBlockProps {
prefill?: IntakePrefillData;
}
// ============================================================================
// Sub-components
// ============================================================================
function PrimaryDiagnosisCard({ diagnosis }: { diagnosis: DiagnosisItem }) {
return (
<div className="p-4 bg-indigo-50 rounded-lg border border-indigo-200">
<div className="flex items-start gap-3">
<div className="p-2 bg-indigo-100 rounded-lg">
<Star className="h-5 w-5 text-indigo-600" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-medium text-indigo-600 uppercase tracking-wide">
Hoofddiagnose
</span>
{diagnosis.code && (
<span className="text-xs text-indigo-500">
{diagnosis.codeSystem}: {diagnosis.code}
</span>
)}
</div>
<h4 className="font-medium text-slate-900">{diagnosis.description}</h4>
{diagnosis.severity && (
<p className="text-sm text-slate-600 mt-1">
Ernst: {diagnosis.severity}
</p>
)}
</div>
</div>
</div>
);
}
function getStatusBadgeVariant(status: string): BadgeVariant {
switch (status) {
case 'active':
case 'recurrence':
case 'relapse':
return 'warning';
case 'resolved':
case 'remission':
return 'success';
case 'inactive':
return 'default';
default:
return 'default';
}
}
function getStatusLabel(status: string): string {
const labels: Record<string, string> = {
active: 'Actief',
recurrence: 'Recidief',
relapse: 'Terugval',
inactive: 'Inactief',
remission: 'Remissie',
resolved: 'Hersteld',
};
return labels[status] || status;
}
function DiagnosisItemRow({ diagnosis }: { diagnosis: DiagnosisItem }) {
const formatDate = (dateStr: string) => {
if (!dateStr) return '';
return new Date(dateStr).toLocaleDateString('nl-NL', {
day: 'numeric',
month: 'short',
year: 'numeric',
});
};
const subtitle = [
diagnosis.code ? `${diagnosis.codeSystem}: ${diagnosis.code}` : null,
formatDate(diagnosis.recordedDate),
]
.filter(Boolean)
.join(' • ');
return (
<BlockItem
title={diagnosis.description}
subtitle={subtitle}
badge={{
label: getStatusLabel(diagnosis.clinicalStatus),
variant: getStatusBadgeVariant(diagnosis.clinicalStatus),
}}
/>
);
}
function DiagnosisSummary({ summary }: { summary: IntakeDiagnoseResponse['summary'] }) {
return (
<div className="flex items-center gap-6 p-3 bg-slate-50 rounded-lg border border-slate-200">
<div className="flex items-center gap-2">
<Activity className="h-4 w-4 text-slate-500" />
<span className="text-sm text-slate-600">
<span className="font-medium">{summary.total}</span> diagnose{summary.total !== 1 ? 's' : ''}
</span>
</div>
{summary.hasActiveConditions && (
<span className="inline-flex items-center px-2 py-0.5 bg-amber-50 text-amber-700 text-xs font-medium rounded-full border border-amber-200">
Actieve condities
</span>
)}
</div>
);
}
// ============================================================================
// Main Component
// ============================================================================
export function DiagnoseBlock({ prefill }: DiagnoseBlockProps) {
const config = BLOCK_CONFIGS.diagnose_query;
const { closeBlock } = useCortexStore();
// Get patient context (from prefill or activePatient)
const { patientId, patientName, hasPatientContext } = useIntakeContext(prefill);
// Fetch diagnosis data
const { data, isLoading, error, refetch } = useBlockData<IntakeDiagnoseResponse>({
endpoint: '/api/cortex/intake/diagnose',
params: {
patientId: patientId || undefined,
intakeId: prefill?.intakeId,
},
enabled: hasPatientContext,
operationName: 'Diagnoses laden',
});
// No patient context
if (!hasPatientContext) {
return (
<BlockContainer title={config.title} size={config.size}>
<BlockEmpty
icon={User}
message="Selecteer eerst een patiënt"
action={{
label: 'Patiënt zoeken',
onClick: () => {
closeBlock();
// TODO: Open zoeken block
},
}}
/>
</BlockContainer>
);
}
// Loading state
if (isLoading) {
return (
<BlockContainer title={config.title} size={config.size}>
<BlockLoading message="Diagnoses laden..." />
</BlockContainer>
);
}
// Error state
if (error) {
return (
<BlockContainer title={config.title} size={config.size}>
<BlockError message={error} onRetry={refetch} />
</BlockContainer>
);
}
// No data
if (!data) {
return (
<BlockContainer title={config.title} size={config.size}>
<BlockEmpty
icon={Stethoscope}
message="Geen diagnoses gevonden"
/>
</BlockContainer>
);
}
// Get secondary diagnoses (non-primary)
const secondaryDiagnoses = data.diagnoses.filter((d) => !d.isPrimary);
return (
<BlockContainer title={config.title} size={config.size}>
<div className="space-y-4">
{/* Patient name header */}
{patientName && (
<div className="text-sm text-slate-600 font-medium">
Patiënt: {patientName}
</div>
)}
{/* Summary stats */}
<DiagnosisSummary summary={data.summary} />
{/* Primary diagnosis */}
{data.summary.primaryDiagnosis ? (
<PrimaryDiagnosisCard diagnosis={data.summary.primaryDiagnosis} />
) : (
<div className="p-4 bg-slate-50 rounded-lg border border-slate-200 border-dashed">
<div className="flex items-center gap-2 text-slate-500">
<Star className="h-4 w-4" />
<span className="text-sm">Geen hoofddiagnose geregistreerd</span>
</div>
</div>
)}
{/* Secondary diagnoses */}
{secondaryDiagnoses.length > 0 ? (
<BlockSection
icon={Stethoscope}
iconColor="text-rose-600"
title="Nevendiagnoses"
count={secondaryDiagnoses.length}
>
<div className="space-y-2">
{secondaryDiagnoses.map((diagnosis) => (
<DiagnosisItemRow key={diagnosis.id} diagnosis={diagnosis} />
))}
</div>
</BlockSection>
) : data.diagnoses.length > 0 ? (
<div className="text-sm text-slate-500 text-center py-4">
Geen nevendiagnoses geregistreerd
</div>
) : null}
{/* No diagnoses at all */}
{data.diagnoses.length === 0 && (
<div className="py-8 text-center">
<Stethoscope className="h-8 w-8 text-slate-300 mx-auto mb-2" />
<p className="text-sm text-slate-500">
Nog geen diagnoses geregistreerd
</p>
</div>
)}
{/* Footer with actions */}
<BlockFooter
secondaryAction={{
label: 'Sluiten',
onClick: closeBlock,
}}
primaryAction={{
label: 'Naar diagnoses',
icon: ExternalLink,
onClick: () => {
// Navigate to diagnosis page
const url = `/epd/patients/${data.patientId}/intakes/${data.intakeId}/diagnosis`;
window.location.href = url;
},
}}
/>
</div>
</BlockContainer>
);
}

View File

@@ -0,0 +1,299 @@
'use client';
/**
* IntakeStatusBlock
*
* Shows intake completion status: percentage, section checklist.
* Uses Cortex shared components and hooks.
*
* Epic: E2.S1 - Intake Blocks
*/
import { useCortexStore } from '@/stores/cortex-store';
import { BlockContainer } from './block-container';
import type { BlockPrefillData } from '@/stores/cortex-store';
import { BLOCK_CONFIGS } from '@/lib/cortex/types';
import {
ClipboardList,
CheckCircle2,
Circle,
User,
ExternalLink,
} from 'lucide-react';
// Use extracted hooks and components
import { useBlockData, useIntakeContext, type IntakePrefillData } from '@/lib/cortex/hooks';
import {
BlockLoading,
BlockError,
BlockEmpty,
BlockSection,
BlockFooter,
} from '@/components/cortex/shared';
// Import response type from API
import type { IntakeStatusResponse } from '@/app/api/cortex/intake/status/route';
// ============================================================================
// Types
// ============================================================================
interface IntakeStatusBlockProps {
prefill?: IntakePrefillData;
}
// ============================================================================
// Sub-components
// ============================================================================
function CompletionRing({ percentage }: { percentage: number }) {
// SVG circle progress ring
const radius = 40;
const circumference = 2 * Math.PI * radius;
const offset = circumference - (percentage / 100) * circumference;
// Color based on percentage
const getColor = () => {
if (percentage >= 80) return 'text-green-500';
if (percentage >= 50) return 'text-amber-500';
return 'text-red-500';
};
return (
<div className="relative w-24 h-24">
<svg className="w-24 h-24 -rotate-90" viewBox="0 0 100 100">
{/* Background circle */}
<circle
cx="50"
cy="50"
r={radius}
fill="none"
stroke="currentColor"
strokeWidth="8"
className="text-slate-200"
/>
{/* Progress circle */}
<circle
cx="50"
cy="50"
r={radius}
fill="none"
stroke="currentColor"
strokeWidth="8"
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={offset}
className={`${getColor()} transition-all duration-500`}
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-xl font-semibold text-slate-700">
{percentage}%
</span>
</div>
</div>
);
}
interface SectionItemProps {
label: string;
completed: boolean;
required: boolean;
count: number;
}
function SectionItem({ label, completed, required, count }: SectionItemProps) {
return (
<div className="flex items-center justify-between py-2 px-3 rounded-lg bg-slate-50 border border-slate-100">
<div className="flex items-center gap-2.5">
{completed ? (
<CheckCircle2 className="h-4 w-4 text-green-500" />
) : (
<Circle className="h-4 w-4 text-slate-300" />
)}
<span
className={`text-sm ${
completed ? 'text-slate-700' : 'text-slate-500'
}`}
>
{label}
</span>
{required && !completed && (
<span className="text-xs text-red-500 font-medium">*</span>
)}
</div>
{count > 0 && (
<span className="text-xs text-slate-400">{count} item{count !== 1 ? 's' : ''}</span>
)}
</div>
);
}
// ============================================================================
// Main Component
// ============================================================================
export function IntakeStatusBlock({ prefill }: IntakeStatusBlockProps) {
const config = BLOCK_CONFIGS.intake_status;
const { closeBlock } = useCortexStore();
// Get patient context (from prefill or activePatient)
const { patientId, patientName, hasPatientContext } = useIntakeContext(prefill);
// Fetch intake status data
const { data, isLoading, error, refetch } = useBlockData<IntakeStatusResponse>({
endpoint: '/api/cortex/intake/status',
params: {
patientId: patientId || undefined,
intakeId: prefill?.intakeId,
},
enabled: hasPatientContext,
operationName: 'Intake status laden',
});
// No patient context
if (!hasPatientContext) {
return (
<BlockContainer title={config.title} size={config.size}>
<BlockEmpty
icon={User}
message="Selecteer eerst een patiënt"
action={{
label: 'Patiënt zoeken',
onClick: () => {
closeBlock();
// TODO: Open zoeken block
},
}}
/>
</BlockContainer>
);
}
// Loading state
if (isLoading) {
return (
<BlockContainer title={config.title} size={config.size}>
<BlockLoading message="Intake status laden..." />
</BlockContainer>
);
}
// Error state
if (error) {
return (
<BlockContainer title={config.title} size={config.size}>
<BlockError message={error} onRetry={refetch} />
</BlockContainer>
);
}
// No data
if (!data) {
return (
<BlockContainer title={config.title} size={config.size}>
<BlockEmpty
icon={ClipboardList}
message="Geen intake gevonden"
/>
</BlockContainer>
);
}
// Separate required and optional sections
const requiredSections = data.sections.filter((s) => s.required);
const optionalSections = data.sections.filter((s) => !s.required);
return (
<BlockContainer title={config.title} size={config.size}>
<div className="space-y-4">
{/* Patient name header */}
{patientName && (
<div className="text-sm text-slate-600 font-medium">
Patiënt: {patientName}
</div>
)}
{/* Completion overview */}
<div className="flex items-center gap-6 p-4 bg-slate-50 rounded-lg border border-slate-200">
<CompletionRing percentage={data.completionPercentage} />
<div className="flex-1">
<h3 className="font-medium text-slate-800">
{data.completionPercentage === 100
? 'Intake compleet!'
: data.completionPercentage >= 80
? 'Bijna klaar'
: 'Intake in uitvoering'}
</h3>
<p className="text-sm text-slate-500 mt-1">
{data.completedCount} van {data.totalRequired} verplichte secties
ingevuld
</p>
{data.status === 'afgerond' && (
<span className="inline-block mt-2 px-2 py-0.5 bg-green-100 text-green-700 text-xs font-medium rounded-full">
Afgerond
</span>
)}
</div>
</div>
{/* Required sections */}
<BlockSection
icon={ClipboardList}
iconColor="text-cyan-600"
title="Verplichte secties"
count={requiredSections.filter((s) => s.completed).length}
>
<div className="space-y-2">
{requiredSections.map((section) => (
<SectionItem
key={section.id}
label={section.label}
completed={section.completed}
required={section.required}
count={section.count}
/>
))}
</div>
</BlockSection>
{/* Optional sections */}
<BlockSection
icon={ClipboardList}
iconColor="text-slate-400"
title="Optionele secties"
count={optionalSections.filter((s) => s.completed).length}
>
<div className="space-y-2">
{optionalSections.map((section) => (
<SectionItem
key={section.id}
label={section.label}
completed={section.completed}
required={section.required}
count={section.count}
/>
))}
</div>
</BlockSection>
{/* Footer with actions */}
<BlockFooter
secondaryAction={{
label: 'Sluiten',
onClick: closeBlock,
}}
primaryAction={{
label: 'Naar intake',
icon: ExternalLink,
onClick: () => {
// Navigate to intake page
const url = `/epd/patients/${data.patientId}/intakes/${data.intakeId}`;
window.location.href = url;
},
}}
/>
</div>
</BlockContainer>
);
}

View File

@@ -0,0 +1,269 @@
'use client';
/**
* RisicoBlock
*
* Shows risk assessments for an intake: level indicators, categories.
* Uses Cortex shared components and hooks.
*
* Epic: E2.S2 - Intake Blocks
*/
import { useCortexStore } from '@/stores/cortex-store';
import { BlockContainer } from './block-container';
import { BLOCK_CONFIGS } from '@/lib/cortex/types';
import {
AlertTriangle,
User,
ExternalLink,
Shield,
AlertCircle,
} from 'lucide-react';
// Use extracted hooks and components
import { useBlockData, useIntakeContext, type IntakePrefillData } from '@/lib/cortex/hooks';
import {
BlockLoading,
BlockError,
BlockEmpty,
BlockSection,
BlockItem,
BlockFooter,
getRiskBadgeVariant,
} from '@/components/cortex/shared';
// Import response type from API
import type { IntakeRisicoResponse, RiskAssessmentItem } from '@/app/api/cortex/intake/risico/route';
// ============================================================================
// Types
// ============================================================================
interface RisicoBlockProps {
prefill?: IntakePrefillData;
}
// ============================================================================
// Sub-components
// ============================================================================
function RiskSummaryCard({ summary }: { summary: IntakeRisicoResponse['summary'] }) {
const getLevelColor = (level: string | null) => {
if (!level) return 'bg-slate-100 border-slate-200';
switch (level) {
case 'acuut':
return 'bg-red-100 border-red-300';
case 'hoog':
return 'bg-red-50 border-red-200';
case 'matig':
return 'bg-amber-50 border-amber-200';
case 'laag':
return 'bg-green-50 border-green-200';
default:
return 'bg-slate-100 border-slate-200';
}
};
const getLevelLabel = (level: string | null) => {
if (!level) return 'Geen';
return level.charAt(0).toUpperCase() + level.slice(1);
};
return (
<div className={`p-4 rounded-lg border ${getLevelColor(summary.highestLevel)}`}>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-slate-600" />
<span className="font-medium text-slate-800">Risico-overzicht</span>
</div>
<span className={`px-2.5 py-1 rounded-full text-sm font-medium ${
summary.highestLevel === 'acuut' || summary.highestLevel === 'hoog'
? 'bg-red-100 text-red-700'
: summary.highestLevel === 'matig'
? 'bg-amber-100 text-amber-700'
: 'bg-green-100 text-green-700'
}`}>
Hoogste: {getLevelLabel(summary.highestLevel)}
</span>
</div>
{/* Risk indicators */}
<div className="flex flex-wrap gap-2">
{summary.hasSuicideRisk && (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-red-100 text-red-700 rounded-full text-xs font-medium">
<AlertCircle className="h-3.5 w-3.5" />
Suïcide
</span>
)}
{summary.hasSelfHarmRisk && (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-amber-100 text-amber-700 rounded-full text-xs font-medium">
<AlertCircle className="h-3.5 w-3.5" />
Zelfbeschadiging
</span>
)}
{summary.hasAggressionRisk && (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-orange-100 text-orange-700 rounded-full text-xs font-medium">
<AlertCircle className="h-3.5 w-3.5" />
Agressie
</span>
)}
{!summary.hasSuicideRisk && !summary.hasSelfHarmRisk && !summary.hasAggressionRisk && summary.total === 0 && (
<span className="text-sm text-slate-500">Geen specifieke risico&apos;s geregistreerd</span>
)}
</div>
<div className="mt-3 text-xs text-slate-500">
{summary.total} risicotaxatie{summary.total !== 1 ? 's' : ''} geregistreerd
</div>
</div>
);
}
function RiskItem({ risk }: { risk: RiskAssessmentItem }) {
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString('nl-NL', {
day: 'numeric',
month: 'short',
year: 'numeric',
});
};
return (
<BlockItem
title={risk.type}
subtitle={`${formatDate(risk.assessmentDate)}${risk.measures ? ' • Maatregelen vastgelegd' : ''}`}
badge={{
label: risk.level.charAt(0).toUpperCase() + risk.level.slice(1),
variant: getRiskBadgeVariant(risk.level),
}}
/>
);
}
// ============================================================================
// Main Component
// ============================================================================
export function RisicoBlock({ prefill }: RisicoBlockProps) {
const config = BLOCK_CONFIGS.risico_query;
const { closeBlock } = useCortexStore();
// Get patient context (from prefill or activePatient)
const { patientId, patientName, hasPatientContext } = useIntakeContext(prefill);
// Fetch risk data
const { data, isLoading, error, refetch } = useBlockData<IntakeRisicoResponse>({
endpoint: '/api/cortex/intake/risico',
params: {
patientId: patientId || undefined,
intakeId: prefill?.intakeId,
},
enabled: hasPatientContext,
operationName: 'Risicotaxaties laden',
});
// No patient context
if (!hasPatientContext) {
return (
<BlockContainer title={config.title} size={config.size}>
<BlockEmpty
icon={User}
message="Selecteer eerst een patiënt"
action={{
label: 'Patiënt zoeken',
onClick: () => {
closeBlock();
// TODO: Open zoeken block
},
}}
/>
</BlockContainer>
);
}
// Loading state
if (isLoading) {
return (
<BlockContainer title={config.title} size={config.size}>
<BlockLoading message="Risicotaxaties laden..." />
</BlockContainer>
);
}
// Error state
if (error) {
return (
<BlockContainer title={config.title} size={config.size}>
<BlockError message={error} onRetry={refetch} />
</BlockContainer>
);
}
// No data
if (!data) {
return (
<BlockContainer title={config.title} size={config.size}>
<BlockEmpty
icon={AlertTriangle}
message="Geen risicotaxaties gevonden"
/>
</BlockContainer>
);
}
return (
<BlockContainer title={config.title} size={config.size}>
<div className="space-y-4">
{/* Patient name header */}
{patientName && (
<div className="text-sm text-slate-600 font-medium">
Patiënt: {patientName}
</div>
)}
{/* Risk summary */}
<RiskSummaryCard summary={data.summary} />
{/* Risk assessments list */}
{data.risks.length > 0 ? (
<BlockSection
icon={AlertTriangle}
iconColor="text-orange-600"
title="Risicotaxaties"
count={data.risks.length}
>
<div className="space-y-2">
{data.risks.map((risk) => (
<RiskItem key={risk.id} risk={risk} />
))}
</div>
</BlockSection>
) : (
<div className="py-8 text-center">
<AlertTriangle className="h-8 w-8 text-slate-300 mx-auto mb-2" />
<p className="text-sm text-slate-500">
Nog geen risicotaxaties geregistreerd
</p>
</div>
)}
{/* Footer with actions */}
<BlockFooter
secondaryAction={{
label: 'Sluiten',
onClick: closeBlock,
}}
primaryAction={{
label: 'Naar risicotaxaties',
icon: ExternalLink,
onClick: () => {
// Navigate to risk page
const url = `/epd/patients/${data.patientId}/intakes/${data.intakeId}/risk`;
window.location.href = url;
},
}}
/>
</div>
</BlockContainer>
);
}

View File

@@ -19,13 +19,13 @@ import { Label } from '@/components/ui/label';
import { Search, User } from 'lucide-react';
// Use extracted hooks and components (DRY)
import { usePatientSearch } from '@/lib/cortex/hooks/use-patient-search';
import { usePatientSelection } from '@/lib/cortex/hooks/use-patient-selection';
import { usePatientSearch, usePatientSelection } from '@/lib/cortex/hooks';
import {
PatientListItem,
PatientListEmpty,
PatientListLoading,
} from '@/components/cortex/shared/patient-list-item';
BlockEmpty,
} from '@/components/cortex/shared';
interface ZoekenBlockProps {
prefill?: BlockPrefillData;
@@ -116,10 +116,10 @@ export function ZoekenBlock({ prefill }: ZoekenBlockProps) {
{/* Empty State - waiting for input */}
{!showResults && (
<div className="flex flex-col items-center justify-center py-8 text-slate-400">
<User className="h-8 w-8 mb-2 opacity-50" />
<p className="text-sm">Typ minimaal 2 karakters om te zoeken</p>
</div>
<BlockEmpty
icon={User}
message="Typ minimaal 2 karakters om te zoeken"
/>
)}
</div>
</BlockContainer>

View File

@@ -12,6 +12,9 @@ import type { BlockType, BlockPrefillData } from '@/stores/cortex-store';
import { DagnotatieBlock } from '../blocks/dagnotitie-block';
import { ZoekenBlock } from '../blocks/zoeken-block';
import { OverdrachtBlock } from '../blocks/overdracht-block';
import { IntakeStatusBlock } from '../blocks/intake-status-block';
import { RisicoBlock } from '../blocks/risico-block';
import { DiagnoseBlock } from '../blocks/diagnose-block';
import { PatientContextCard } from '../blocks/patient-context-card';
import { FallbackPicker } from '../blocks/fallback-picker';
@@ -26,6 +29,13 @@ export function CanvasArea() {
return <ZoekenBlock prefill={prefill} />;
case 'overdracht':
return <OverdrachtBlock prefill={prefill} />;
// Intake blocks (MVP)
case 'intake_status':
return <IntakeStatusBlock prefill={prefill} />;
case 'risico_query':
return <RisicoBlock prefill={prefill} />;
case 'diagnose_query':
return <DiagnoseBlock prefill={prefill} />;
case 'fallback':
return <FallbackPicker originalInput={prefill.content} />;
default:

View File

@@ -8,7 +8,10 @@
*/
import { useCortexStore, type CortexIntent } from '@/stores/cortex-store';
import { FileText, Search, ArrowRightLeft, HelpCircle, Clock, Calendar, Plus, X } from 'lucide-react';
import {
FileText, Search, ArrowRightLeft, HelpCircle, Clock, Calendar, Plus, X,
ClipboardList, AlertTriangle, Stethoscope, Navigation,
} from 'lucide-react';
const INTENT_CONFIG: Record<CortexIntent, { icon: typeof FileText; color: string; label: string }> = {
dagnotitie: { icon: FileText, color: 'text-blue-600 bg-blue-50 border border-blue-200', label: 'Notitie' },
@@ -18,6 +21,11 @@ const INTENT_CONFIG: Record<CortexIntent, { icon: typeof FileText; color: string
create_appointment: { icon: Plus, color: 'text-green-600 bg-green-50 border border-green-200', label: 'Afspraak' },
cancel_appointment: { icon: X, color: 'text-red-600 bg-red-50 border border-red-200', label: 'Annuleren' },
reschedule_appointment: { icon: Clock, color: 'text-amber-600 bg-amber-50 border border-amber-200', label: 'Verzetten' },
// Intake intents (MVP)
intake_status: { icon: ClipboardList, color: 'text-cyan-600 bg-cyan-50 border border-cyan-200', label: 'Status' },
intake_navigeer: { icon: Navigation, color: 'text-indigo-600 bg-indigo-50 border border-indigo-200', label: 'Navigeer' },
risico_query: { icon: AlertTriangle, color: 'text-orange-600 bg-orange-50 border border-orange-200', label: 'Risico' },
diagnose_query: { icon: Stethoscope, color: 'text-rose-600 bg-rose-50 border border-rose-200', label: 'Diagnose' },
unknown: { icon: HelpCircle, color: 'text-slate-600 bg-slate-50 border border-slate-200', label: 'Actie' },
};
@@ -34,18 +42,27 @@ function formatRelativeTime(date: Date): string {
}
export function RecentStrip() {
const { recentActions, setInputValue, openBlock } = useCortexStore();
const { recentActions, setInputValue, openBlock, activePatient } = useCortexStore();
const handleActionClick = (action: typeof recentActions[0]) => {
// Set the input to repeat the action
setInputValue(action.label);
// If it's a known intent, open the block directly
if (action.intent !== 'unknown') {
openBlock(action.intent, {
patientName: action.patientName,
});
// Skip 'unknown' intent
if (action.intent === 'unknown') return;
// intake_navigeer is navigation-only (no block)
if (action.intent === 'intake_navigeer') {
// For navigation, we need patient context
// TODO: Implement navigation when patient + intake context is available
console.log('[RecentStrip] intake_navigeer - navigation not yet implemented');
return;
}
// Open the block for other intents
openBlock(action.intent, {
patientName: action.patientName,
});
};
return (

View File

@@ -0,0 +1,103 @@
'use client';
/**
* BlockFooter Component
*
* Footer with action buttons for Cortex blocks.
* Secondary action on left, primary action on right.
*
* Epic: E1.S3 - Block Layout
*/
import type { LucideIcon } from 'lucide-react';
import { Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
// ============================================================================
// Types
// ============================================================================
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;
}
// ============================================================================
// Component
// ============================================================================
/**
* Footer for Cortex blocks with action buttons.
* Returns null if no actions provided.
*/
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>
);
}

View File

@@ -0,0 +1,128 @@
'use client';
/**
* BlockItem Component
*
* List item for use within BlockSection.
* Supports title, subtitle, badge, and click handling.
*
* Epic: E1.S2 - Block Layout
*/
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
// ============================================================================
/**
* List item for Cortex blocks.
* Renders as button when clickable, div otherwise.
*/
export function BlockItem({
title,
subtitle,
badge,
onClick,
className,
}: BlockItemProps) {
const isClickable = Boolean(onClick);
const Component = isClickable ? 'button' : 'div';
return (
<Component
type={isClickable ? 'button' : undefined}
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
// ============================================================================
/**
* Maps risk level string to badge variant.
* Used for consistent risk display across blocks.
*/
export function getRiskBadgeVariant(level: string): BadgeVariant {
switch (level.toLowerCase()) {
case 'laag':
return 'success';
case 'gemiddeld':
case 'matig':
return 'warning';
case 'hoog':
case 'zeer_hoog':
case 'zeer hoog':
return 'danger';
default:
return 'default';
}
}

View File

@@ -0,0 +1,62 @@
'use client';
/**
* BlockSection Component
*
* Groups related content within a Cortex block with a header.
*
* Epic: E1.S1 - Block Layout
*/
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;
}
/**
* Section wrapper for Cortex blocks.
* Shows icon + title header with optional count, wraps children content.
*/
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>
);
}

View File

@@ -0,0 +1,136 @@
'use client';
/**
* Block State Components
*
* Reusable state components for Cortex blocks:
* - BlockLoading: Loading spinner with message
* - BlockError: Error state with retry option
* - BlockEmpty: Empty state with action option
*
* Epic: E0 - Block States
*/
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 (E0.S1)
// ============================================================================
interface BlockLoadingProps {
/** Tekst onder de spinner */
message?: string;
/** Extra CSS classes */
className?: string;
}
/**
* Loading state for Cortex blocks.
* Shows centered spinner with optional message.
*/
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 (E0.S2)
// ============================================================================
interface BlockErrorProps {
/** Foutmelding tekst */
message: string;
/** Callback voor retry knop (toont knop indien aanwezig) */
onRetry?: () => void;
/** Extra CSS classes */
className?: string;
}
/**
* Error state for Cortex blocks.
* Shows error icon, message, and optional retry button.
*/
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 (E0.S3)
// ============================================================================
interface BlockEmptyProps {
/** Icoon component */
icon: LucideIcon;
/** Hoofdboodschap */
message: string;
/** Optionele actie knop */
action?: {
label: string;
onClick: () => void;
};
/** Extra CSS classes */
className?: string;
}
/**
* Empty state for Cortex blocks.
* Shows icon, message, and optional action button.
*/
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>
);
}

View File

@@ -0,0 +1,21 @@
/**
* Cortex Shared Components
*
* Reusable components for Cortex blocks.
*/
// Block States (E0)
export { BlockLoading, BlockError, BlockEmpty } from './block-states';
// Block Layout (E1)
export { BlockSection } from './block-section';
export { BlockItem, getRiskBadgeVariant, type BadgeVariant } from './block-item';
export { BlockFooter } from './block-footer';
// Patient Components (existing)
export {
PatientListItem,
PatientListEmpty,
PatientListLoading,
} from './patient-list-item';
export { LinkedEvidence } from './linked-evidence';