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>
300 lines
8.6 KiB
TypeScript
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>
|
|
);
|
|
}
|