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:
303
components/cortex/blocks/diagnose-block.tsx
Normal file
303
components/cortex/blocks/diagnose-block.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
299
components/cortex/blocks/intake-status-block.tsx
Normal file
299
components/cortex/blocks/intake-status-block.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
269
components/cortex/blocks/risico-block.tsx
Normal file
269
components/cortex/blocks/risico-block.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user