Files
triqura-ecd/components/cortex/blocks/risico-block.tsx
colinislit b095b1e492 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>
2026-02-03 23:21:18 +01:00

270 lines
8.0 KiB
TypeScript

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