Files
triqura-ecd/components/cortex/blocks/intake-status-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

300 lines
8.6 KiB
TypeScript

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