diff --git a/app/api/cortex/intake/diagnose/route.ts b/app/api/cortex/intake/diagnose/route.ts new file mode 100644 index 0000000..95b1fae --- /dev/null +++ b/app/api/cortex/intake/diagnose/route.ts @@ -0,0 +1,183 @@ +/** + * Intake Diagnosis API + * + * GET /api/cortex/intake/diagnose?patientId=xxx&intakeId=xxx + * + * Returns diagnoses for an intake. + * Used by DiagnoseBlock in Cortex Command Center. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/lib/auth/server'; +import { z } from 'zod'; + +// Query parameter schema +const QuerySchema = z.object({ + patientId: z.string().uuid({ message: 'patientId moet een geldige UUID zijn' }), + intakeId: z.string().uuid({ message: 'intakeId moet een geldige UUID zijn' }).optional(), +}); + +// Clinical status type +type ClinicalStatus = 'active' | 'recurrence' | 'relapse' | 'inactive' | 'remission' | 'resolved'; + +export interface DiagnosisItem { + id: string; + code: string; + description: string; + codeSystem: string; + clinicalStatus: ClinicalStatus; + severity: string | null; + isPrimary: boolean; + notes: string | null; + recordedDate: string; +} + +export interface IntakeDiagnoseResponse { + intakeId: string; + patientId: string; + diagnoses: DiagnosisItem[]; + summary: { + total: number; + primaryDiagnosis: DiagnosisItem | null; + secondaryCount: number; + hasActiveConditions: boolean; + }; + lastUpdated: string | null; +} + +// Dutch labels for clinical status (not exported - Route files can only export route handlers) +const CLINICAL_STATUS_LABELS: Record = { + active: 'Actief', + recurrence: 'Recidief', + relapse: 'Terugval', + inactive: 'Inactief', + remission: 'Remissie', + resolved: 'Hersteld', +}; + +export async function GET(request: NextRequest) { + try { + // Auth check + const supabase = await createClient(); + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser(); + + if (authError || !user) { + return NextResponse.json( + { error: 'Niet geautoriseerd. Log opnieuw in.' }, + { status: 401 } + ); + } + + // Parse and validate query parameters + const searchParams = request.nextUrl.searchParams; + const patientId = searchParams.get('patientId'); + const intakeId = searchParams.get('intakeId'); + + if (!patientId) { + return NextResponse.json( + { error: 'Query parameter "patientId" is verplicht' }, + { status: 400 } + ); + } + + const validation = QuerySchema.safeParse({ patientId, intakeId }); + if (!validation.success) { + const errorMessage = validation.error.issues + .map((e) => e.message) + .join(', '); + return NextResponse.json({ error: errorMessage }, { status: 400 }); + } + + // If no intakeId provided, get the most recent active intake for this patient + let targetIntakeId = validation.data.intakeId; + + if (!targetIntakeId) { + const { data: latestIntake, error: intakeError } = await supabase + .from('intakes') + .select('id') + .eq('patient_id', patientId) + .eq('status', 'bezig') + .order('start_date', { ascending: false }) + .limit(1) + .maybeSingle(); + + if (intakeError) { + console.error('Error fetching latest intake:', intakeError); + return NextResponse.json( + { error: 'Fout bij ophalen intake' }, + { status: 500 } + ); + } + + if (!latestIntake) { + return NextResponse.json( + { error: 'Geen actieve intake gevonden voor deze patiënt' }, + { status: 404 } + ); + } + + targetIntakeId = latestIntake.id; + } + + // Fetch diagnoses (conditions linked to intake via encounter_id) + const { data: conditionsData, error: conditionsError } = await supabase + .from('conditions') + .select('*') + .eq('encounter_id', targetIntakeId) + .order('recorded_date', { ascending: false }); + + if (conditionsError) { + console.error('Error fetching diagnoses:', conditionsError); + return NextResponse.json( + { error: 'Fout bij ophalen diagnoses' }, + { status: 500 } + ); + } + + const diagnoses: DiagnosisItem[] = (conditionsData || []).map((condition) => ({ + id: condition.id, + code: condition.code_code || '', + description: condition.code_display || 'Onbekende diagnose', + codeSystem: condition.code_system || 'ICD-10', + clinicalStatus: (condition.clinical_status as ClinicalStatus) || 'active', + severity: condition.severity_display, + isPrimary: condition.category === 'primary-diagnosis', + notes: condition.note, + recordedDate: condition.recorded_date || '', + })); + + // Build summary + const primaryDiagnosis = diagnoses.find((d) => d.isPrimary) || null; + const secondaryCount = diagnoses.filter((d) => !d.isPrimary).length; + const hasActiveConditions = diagnoses.some( + (d) => d.clinicalStatus === 'active' || d.clinicalStatus === 'recurrence' || d.clinicalStatus === 'relapse' + ); + + // Get last updated timestamp + const lastUpdated = diagnoses.length > 0 ? diagnoses[0].recordedDate : null; + + const response: IntakeDiagnoseResponse = { + intakeId: targetIntakeId, + patientId, + diagnoses, + summary: { + total: diagnoses.length, + primaryDiagnosis, + secondaryCount, + hasActiveConditions, + }, + lastUpdated, + }; + + return NextResponse.json(response, { status: 200 }); + } catch (error) { + console.error('Error in intake diagnosis API:', error); + return NextResponse.json( + { error: 'Er ging iets mis bij het ophalen van de diagnoses.' }, + { status: 500 } + ); + } +} diff --git a/app/api/cortex/intake/risico/route.ts b/app/api/cortex/intake/risico/route.ts new file mode 100644 index 0000000..9a8c42c --- /dev/null +++ b/app/api/cortex/intake/risico/route.ts @@ -0,0 +1,201 @@ +/** + * Intake Risk Assessment API + * + * GET /api/cortex/intake/risico?patientId=xxx&intakeId=xxx + * + * Returns risk assessments for an intake. + * Used by RisicoBlock in Cortex Command Center. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/lib/auth/server'; +import { z } from 'zod'; + +// Query parameter schema +const QuerySchema = z.object({ + patientId: z.string().uuid({ message: 'patientId moet een geldige UUID zijn' }), + intakeId: z.string().uuid({ message: 'intakeId moet een geldige UUID zijn' }).optional(), +}); + +// Risk level type +type RiskLevel = 'laag' | 'matig' | 'hoog' | 'acuut'; + +export interface RiskAssessmentItem { + id: string; + type: string; + level: RiskLevel; + rationale: string; + measures: string | null; + assessmentDate: string; + evaluationDate: string | null; + notes: string | null; +} + +export interface IntakeRisicoResponse { + intakeId: string; + patientId: string; + risks: RiskAssessmentItem[]; + summary: { + total: number; + highestLevel: RiskLevel | null; + hasSuicideRisk: boolean; + hasSelfHarmRisk: boolean; + hasAggressionRisk: boolean; + }; + lastUpdated: string | null; +} + +// Map risk type to category for summary +function categorizeRiskType(type: string): 'suicide' | 'selfharm' | 'aggression' | 'other' { + const lowerType = type.toLowerCase(); + if (lowerType.includes('suïcid') || lowerType.includes('suicide') || lowerType.includes('suicid')) { + return 'suicide'; + } + if (lowerType.includes('zelfbeschadig') || lowerType.includes('automutil')) { + return 'selfharm'; + } + if (lowerType.includes('agressie') || lowerType.includes('geweld')) { + return 'aggression'; + } + return 'other'; +} + +// Determine highest risk level +function getHighestLevel(levels: RiskLevel[]): RiskLevel | null { + if (levels.length === 0) return null; + const priority: Record = { + laag: 1, + matig: 2, + hoog: 3, + acuut: 4, + }; + return levels.reduce((highest, level) => { + return priority[level] > priority[highest] ? level : highest; + }); +} + +export async function GET(request: NextRequest) { + try { + // Auth check + const supabase = await createClient(); + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser(); + + if (authError || !user) { + return NextResponse.json( + { error: 'Niet geautoriseerd. Log opnieuw in.' }, + { status: 401 } + ); + } + + // Parse and validate query parameters + const searchParams = request.nextUrl.searchParams; + const patientId = searchParams.get('patientId'); + const intakeId = searchParams.get('intakeId'); + + if (!patientId) { + return NextResponse.json( + { error: 'Query parameter "patientId" is verplicht' }, + { status: 400 } + ); + } + + const validation = QuerySchema.safeParse({ patientId, intakeId }); + if (!validation.success) { + const errorMessage = validation.error.issues + .map((e) => e.message) + .join(', '); + return NextResponse.json({ error: errorMessage }, { status: 400 }); + } + + // If no intakeId provided, get the most recent active intake for this patient + let targetIntakeId = validation.data.intakeId; + + if (!targetIntakeId) { + const { data: latestIntake, error: intakeError } = await supabase + .from('intakes') + .select('id') + .eq('patient_id', patientId) + .eq('status', 'bezig') + .order('start_date', { ascending: false }) + .limit(1) + .maybeSingle(); + + if (intakeError) { + console.error('Error fetching latest intake:', intakeError); + return NextResponse.json( + { error: 'Fout bij ophalen intake' }, + { status: 500 } + ); + } + + if (!latestIntake) { + return NextResponse.json( + { error: 'Geen actieve intake gevonden voor deze patiënt' }, + { status: 404 } + ); + } + + targetIntakeId = latestIntake.id; + } + + // Fetch risk assessments + const { data: riskData, error: riskError } = await supabase + .from('risk_assessments') + .select('*') + .eq('intake_id', targetIntakeId) + .order('assessment_date', { ascending: false }); + + if (riskError) { + console.error('Error fetching risk assessments:', riskError); + return NextResponse.json( + { error: 'Fout bij ophalen risicotaxaties' }, + { status: 500 } + ); + } + + const risks: RiskAssessmentItem[] = (riskData || []).map((risk) => ({ + id: risk.id, + type: risk.risk_type, + level: risk.risk_level as RiskLevel, + rationale: risk.rationale, + measures: risk.measures, + assessmentDate: risk.assessment_date, + evaluationDate: risk.evaluation_date, + notes: risk.notes, + })); + + // Build summary + const riskCategories = risks.map((r) => categorizeRiskType(r.type)); + const riskLevels = risks.map((r) => r.level); + + const summary = { + total: risks.length, + highestLevel: getHighestLevel(riskLevels), + hasSuicideRisk: riskCategories.includes('suicide'), + hasSelfHarmRisk: riskCategories.includes('selfharm'), + hasAggressionRisk: riskCategories.includes('aggression'), + }; + + // Get last updated timestamp + const lastUpdated = risks.length > 0 ? risks[0].assessmentDate : null; + + const response: IntakeRisicoResponse = { + intakeId: targetIntakeId, + patientId, + risks, + summary, + lastUpdated, + }; + + return NextResponse.json(response, { status: 200 }); + } catch (error) { + console.error('Error in intake risk API:', error); + return NextResponse.json( + { error: 'Er ging iets mis bij het ophalen van de risicotaxaties.' }, + { status: 500 } + ); + } +} diff --git a/app/api/cortex/intake/status/route.ts b/app/api/cortex/intake/status/route.ts new file mode 100644 index 0000000..a5a532d --- /dev/null +++ b/app/api/cortex/intake/status/route.ts @@ -0,0 +1,225 @@ +/** + * Intake Status API + * + * GET /api/cortex/intake/status?patientId=xxx&intakeId=xxx + * + * Returns intake completion status: percentage, completed/missing sections. + * Used by IntakeStatusBlock in Cortex Command Center. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/lib/auth/server'; +import { z } from 'zod'; + +// Query parameter schema +const QuerySchema = z.object({ + patientId: z.string().uuid({ message: 'patientId moet een geldige UUID zijn' }), + intakeId: z.string().uuid({ message: 'intakeId moet een geldige UUID zijn' }).optional(), +}); + +// Section definition +interface IntakeSection { + id: string; + label: string; + required: boolean; +} + +const INTAKE_SECTIONS: IntakeSection[] = [ + { id: 'contacts', label: 'Contactmomenten', required: false }, + { id: 'anamnese', label: 'Anamnese', required: true }, + { id: 'risk', label: 'Risicotaxatie', required: true }, + { id: 'kindcheck', label: 'Kindcheck', required: true }, + { id: 'examination', label: 'Onderzoek', required: false }, + { id: 'rom', label: 'Meetinstrumenten (ROM)', required: false }, + { id: 'diagnosis', label: 'Diagnose', required: true }, + { id: 'behandeladvies', label: 'Behandeladvies', required: true }, +]; + +export interface IntakeStatusResponse { + intakeId: string; + patientId: string; + completionPercentage: number; + status: 'bezig' | 'afgerond'; + sections: { + id: string; + label: string; + required: boolean; + completed: boolean; + count: number; + }[]; + completedCount: number; + totalRequired: number; + lastUpdated: string | null; +} + +export async function GET(request: NextRequest) { + try { + // Auth check + const supabase = await createClient(); + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser(); + + if (authError || !user) { + return NextResponse.json( + { error: 'Niet geautoriseerd. Log opnieuw in.' }, + { status: 401 } + ); + } + + // Parse and validate query parameters + const searchParams = request.nextUrl.searchParams; + const patientId = searchParams.get('patientId'); + const intakeId = searchParams.get('intakeId'); + + if (!patientId) { + return NextResponse.json( + { error: 'Query parameter "patientId" is verplicht' }, + { status: 400 } + ); + } + + const validation = QuerySchema.safeParse({ patientId, intakeId }); + if (!validation.success) { + const errorMessage = validation.error.issues + .map((e) => e.message) + .join(', '); + return NextResponse.json({ error: errorMessage }, { status: 400 }); + } + + // If no intakeId provided, get the most recent active intake for this patient + let targetIntakeId = validation.data.intakeId; + + if (!targetIntakeId) { + const { data: latestIntake, error: intakeError } = await supabase + .from('intakes') + .select('id') + .eq('patient_id', patientId) + .eq('status', 'bezig') + .order('start_date', { ascending: false }) + .limit(1) + .maybeSingle(); + + if (intakeError) { + console.error('Error fetching latest intake:', intakeError); + return NextResponse.json( + { error: 'Fout bij ophalen intake' }, + { status: 500 } + ); + } + + if (!latestIntake) { + return NextResponse.json( + { error: 'Geen actieve intake gevonden voor deze patiënt' }, + { status: 404 } + ); + } + + targetIntakeId = latestIntake.id; + } + + // Fetch intake details + const { data: intake, error: intakeDetailError } = await supabase + .from('intakes') + .select('id, patient_id, status, kindcheck_data, treatment_advice, updated_at') + .eq('id', targetIntakeId) + .single(); + + if (intakeDetailError || !intake) { + return NextResponse.json( + { error: 'Intake niet gevonden' }, + { status: 404 } + ); + } + + // Fetch counts for each section in parallel + const [ + contactsResult, + anamneseResult, + riskResult, + examinationResult, + romResult, + diagnosisResult, + ] = await Promise.all([ + // Contacts (encounters) + supabase + .from('encounters') + .select('id', { count: 'exact', head: true }) + .eq('intake_id', targetIntakeId), + // Anamnese + supabase + .from('anamneses') + .select('id', { count: 'exact', head: true }) + .eq('intake_id', targetIntakeId), + // Risk assessments + supabase + .from('risk_assessments') + .select('id', { count: 'exact', head: true }) + .eq('intake_id', targetIntakeId), + // Examinations (non-ROM) + supabase + .from('examinations') + .select('id', { count: 'exact', head: true }) + .eq('intake_id', targetIntakeId) + .neq('examination_type', 'ROM'), + // ROM examinations + supabase + .from('examinations') + .select('id', { count: 'exact', head: true }) + .eq('intake_id', targetIntakeId) + .eq('examination_type', 'ROM'), + // Diagnoses (conditions linked to intake via encounter_id) + supabase + .from('conditions') + .select('id', { count: 'exact', head: true }) + .eq('encounter_id', targetIntakeId), + ]); + + // Build section status + const sectionCounts: Record = { + contacts: contactsResult.count || 0, + anamnese: anamneseResult.count || 0, + risk: riskResult.count || 0, + kindcheck: intake.kindcheck_data && Object.keys(intake.kindcheck_data as object).length > 0 ? 1 : 0, + examination: examinationResult.count || 0, + rom: romResult.count || 0, + diagnosis: diagnosisResult.count || 0, + behandeladvies: intake.treatment_advice && Object.keys(intake.treatment_advice as object).length > 0 ? 1 : 0, + }; + + const sections = INTAKE_SECTIONS.map((section) => ({ + id: section.id, + label: section.label, + required: section.required, + completed: sectionCounts[section.id] > 0, + count: sectionCounts[section.id], + })); + + // Calculate completion percentage (based on required sections) + const requiredSections = sections.filter((s) => s.required); + const completedRequired = requiredSections.filter((s) => s.completed).length; + const completionPercentage = Math.round( + (completedRequired / requiredSections.length) * 100 + ); + + const response: IntakeStatusResponse = { + intakeId: targetIntakeId, + patientId: intake.patient_id, + completionPercentage, + status: intake.status as 'bezig' | 'afgerond', + sections, + completedCount: completedRequired, + totalRequired: requiredSections.length, + lastUpdated: intake.updated_at, + }; + + return NextResponse.json(response, { status: 200 }); + } catch (error) { + console.error('Error in intake status API:', error); + return NextResponse.json( + { error: 'Er ging iets mis bij het ophalen van de intake status.' }, + { status: 500 } + ); + } +} diff --git a/components/cortex/blocks/diagnose-block.tsx b/components/cortex/blocks/diagnose-block.tsx new file mode 100644 index 0000000..50b5694 --- /dev/null +++ b/components/cortex/blocks/diagnose-block.tsx @@ -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 ( +
+
+
+ +
+
+
+ + Hoofddiagnose + + {diagnosis.code && ( + + {diagnosis.codeSystem}: {diagnosis.code} + + )} +
+

{diagnosis.description}

+ {diagnosis.severity && ( +

+ Ernst: {diagnosis.severity} +

+ )} +
+
+
+ ); +} + +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 = { + 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 ( + + ); +} + +function DiagnosisSummary({ summary }: { summary: IntakeDiagnoseResponse['summary'] }) { + return ( +
+
+ + + {summary.total} diagnose{summary.total !== 1 ? 's' : ''} + +
+ {summary.hasActiveConditions && ( + + Actieve condities + + )} +
+ ); +} + +// ============================================================================ +// 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({ + endpoint: '/api/cortex/intake/diagnose', + params: { + patientId: patientId || undefined, + intakeId: prefill?.intakeId, + }, + enabled: hasPatientContext, + operationName: 'Diagnoses laden', + }); + + // No patient context + if (!hasPatientContext) { + return ( + + { + closeBlock(); + // TODO: Open zoeken block + }, + }} + /> + + ); + } + + // Loading state + if (isLoading) { + return ( + + + + ); + } + + // Error state + if (error) { + return ( + + + + ); + } + + // No data + if (!data) { + return ( + + + + ); + } + + // Get secondary diagnoses (non-primary) + const secondaryDiagnoses = data.diagnoses.filter((d) => !d.isPrimary); + + return ( + +
+ {/* Patient name header */} + {patientName && ( +
+ Patiënt: {patientName} +
+ )} + + {/* Summary stats */} + + + {/* Primary diagnosis */} + {data.summary.primaryDiagnosis ? ( + + ) : ( +
+
+ + Geen hoofddiagnose geregistreerd +
+
+ )} + + {/* Secondary diagnoses */} + {secondaryDiagnoses.length > 0 ? ( + +
+ {secondaryDiagnoses.map((diagnosis) => ( + + ))} +
+
+ ) : data.diagnoses.length > 0 ? ( +
+ Geen nevendiagnoses geregistreerd +
+ ) : null} + + {/* No diagnoses at all */} + {data.diagnoses.length === 0 && ( +
+ +

+ Nog geen diagnoses geregistreerd +

+
+ )} + + {/* Footer with actions */} + { + // Navigate to diagnosis page + const url = `/epd/patients/${data.patientId}/intakes/${data.intakeId}/diagnosis`; + window.location.href = url; + }, + }} + /> +
+
+ ); +} diff --git a/components/cortex/blocks/intake-status-block.tsx b/components/cortex/blocks/intake-status-block.tsx new file mode 100644 index 0000000..2c4032c --- /dev/null +++ b/components/cortex/blocks/intake-status-block.tsx @@ -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 ( +
+ + {/* Background circle */} + + {/* Progress circle */} + + +
+ + {percentage}% + +
+
+ ); +} + +interface SectionItemProps { + label: string; + completed: boolean; + required: boolean; + count: number; +} + +function SectionItem({ label, completed, required, count }: SectionItemProps) { + return ( +
+
+ {completed ? ( + + ) : ( + + )} + + {label} + + {required && !completed && ( + * + )} +
+ {count > 0 && ( + {count} item{count !== 1 ? 's' : ''} + )} +
+ ); +} + +// ============================================================================ +// 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({ + endpoint: '/api/cortex/intake/status', + params: { + patientId: patientId || undefined, + intakeId: prefill?.intakeId, + }, + enabled: hasPatientContext, + operationName: 'Intake status laden', + }); + + // No patient context + if (!hasPatientContext) { + return ( + + { + closeBlock(); + // TODO: Open zoeken block + }, + }} + /> + + ); + } + + // Loading state + if (isLoading) { + return ( + + + + ); + } + + // Error state + if (error) { + return ( + + + + ); + } + + // No data + if (!data) { + return ( + + + + ); + } + + // Separate required and optional sections + const requiredSections = data.sections.filter((s) => s.required); + const optionalSections = data.sections.filter((s) => !s.required); + + return ( + +
+ {/* Patient name header */} + {patientName && ( +
+ Patiënt: {patientName} +
+ )} + + {/* Completion overview */} +
+ +
+

+ {data.completionPercentage === 100 + ? 'Intake compleet!' + : data.completionPercentage >= 80 + ? 'Bijna klaar' + : 'Intake in uitvoering'} +

+

+ {data.completedCount} van {data.totalRequired} verplichte secties + ingevuld +

+ {data.status === 'afgerond' && ( + + Afgerond + + )} +
+
+ + {/* Required sections */} + s.completed).length} + > +
+ {requiredSections.map((section) => ( + + ))} +
+
+ + {/* Optional sections */} + s.completed).length} + > +
+ {optionalSections.map((section) => ( + + ))} +
+
+ + {/* Footer with actions */} + { + // Navigate to intake page + const url = `/epd/patients/${data.patientId}/intakes/${data.intakeId}`; + window.location.href = url; + }, + }} + /> +
+
+ ); +} diff --git a/components/cortex/blocks/risico-block.tsx b/components/cortex/blocks/risico-block.tsx new file mode 100644 index 0000000..393f4f4 --- /dev/null +++ b/components/cortex/blocks/risico-block.tsx @@ -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 ( +
+
+
+ + Risico-overzicht +
+ + Hoogste: {getLevelLabel(summary.highestLevel)} + +
+ + {/* Risk indicators */} +
+ {summary.hasSuicideRisk && ( + + + Suïcide + + )} + {summary.hasSelfHarmRisk && ( + + + Zelfbeschadiging + + )} + {summary.hasAggressionRisk && ( + + + Agressie + + )} + {!summary.hasSuicideRisk && !summary.hasSelfHarmRisk && !summary.hasAggressionRisk && summary.total === 0 && ( + Geen specifieke risico's geregistreerd + )} +
+ +
+ {summary.total} risicotaxatie{summary.total !== 1 ? 's' : ''} geregistreerd +
+
+ ); +} + +function RiskItem({ risk }: { risk: RiskAssessmentItem }) { + const formatDate = (dateStr: string) => { + return new Date(dateStr).toLocaleDateString('nl-NL', { + day: 'numeric', + month: 'short', + year: 'numeric', + }); + }; + + return ( + + ); +} + +// ============================================================================ +// 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({ + endpoint: '/api/cortex/intake/risico', + params: { + patientId: patientId || undefined, + intakeId: prefill?.intakeId, + }, + enabled: hasPatientContext, + operationName: 'Risicotaxaties laden', + }); + + // No patient context + if (!hasPatientContext) { + return ( + + { + closeBlock(); + // TODO: Open zoeken block + }, + }} + /> + + ); + } + + // Loading state + if (isLoading) { + return ( + + + + ); + } + + // Error state + if (error) { + return ( + + + + ); + } + + // No data + if (!data) { + return ( + + + + ); + } + + return ( + +
+ {/* Patient name header */} + {patientName && ( +
+ Patiënt: {patientName} +
+ )} + + {/* Risk summary */} + + + {/* Risk assessments list */} + {data.risks.length > 0 ? ( + +
+ {data.risks.map((risk) => ( + + ))} +
+
+ ) : ( +
+ +

+ Nog geen risicotaxaties geregistreerd +

+
+ )} + + {/* Footer with actions */} + { + // Navigate to risk page + const url = `/epd/patients/${data.patientId}/intakes/${data.intakeId}/risk`; + window.location.href = url; + }, + }} + /> +
+
+ ); +} diff --git a/components/cortex/blocks/zoeken-block.tsx b/components/cortex/blocks/zoeken-block.tsx index 7fb7908..668968a 100644 --- a/components/cortex/blocks/zoeken-block.tsx +++ b/components/cortex/blocks/zoeken-block.tsx @@ -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 && ( -
- -

Typ minimaal 2 karakters om te zoeken

-
+ )} diff --git a/components/cortex/command-center/canvas-area.tsx b/components/cortex/command-center/canvas-area.tsx index 46e1ad0..eae1e60 100644 --- a/components/cortex/command-center/canvas-area.tsx +++ b/components/cortex/command-center/canvas-area.tsx @@ -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 ; case 'overdracht': return ; + // Intake blocks (MVP) + case 'intake_status': + return ; + case 'risico_query': + return ; + case 'diagnose_query': + return ; case 'fallback': return ; default: diff --git a/components/cortex/command-center/recent-strip.tsx b/components/cortex/command-center/recent-strip.tsx index 586b037..346e412 100644 --- a/components/cortex/command-center/recent-strip.tsx +++ b/components/cortex/command-center/recent-strip.tsx @@ -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 = { dagnotitie: { icon: FileText, color: 'text-blue-600 bg-blue-50 border border-blue-200', label: 'Notitie' }, @@ -18,6 +21,11 @@ const INTENT_CONFIG: Record { // 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 ( diff --git a/components/cortex/shared/block-footer.tsx b/components/cortex/shared/block-footer.tsx new file mode 100644 index 0000000..3d906f3 --- /dev/null +++ b/components/cortex/shared/block-footer.tsx @@ -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 ( +
+ {/* Secondary Action (left) */} + {secondaryAction ? ( + + ) : ( +
// Spacer + )} + + {/* Primary Action (right) */} + {primaryAction && ( + + )} +
+ ); +} diff --git a/components/cortex/shared/block-item.tsx b/components/cortex/shared/block-item.tsx new file mode 100644 index 0000000..e8475e1 --- /dev/null +++ b/components/cortex/shared/block-item.tsx @@ -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 = { + 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 ( + + {/* Content */} +
+

{title}

+ {subtitle && ( +

{subtitle}

+ )} +
+ + {/* Badge */} + {badge && ( + + {badge.label} + + )} +
+ ); +} + +// ============================================================================ +// 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'; + } +} diff --git a/components/cortex/shared/block-section.tsx b/components/cortex/shared/block-section.tsx new file mode 100644 index 0000000..fd35686 --- /dev/null +++ b/components/cortex/shared/block-section.tsx @@ -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 ( +
+ {/* Header */} +
+ +

{title}

+ {count !== undefined && ( + ({count}) + )} +
+ + {/* Content */} + {children} +
+ ); +} diff --git a/components/cortex/shared/block-states.tsx b/components/cortex/shared/block-states.tsx new file mode 100644 index 0000000..5e58666 --- /dev/null +++ b/components/cortex/shared/block-states.tsx @@ -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 ( +
+ + {message} +
+ ); +} + +// ============================================================================ +// 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 ( +
+ +

{message}

+ {onRetry && ( + + )} +
+ ); +} + +// ============================================================================ +// 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 ( +
+ +

{message}

+ {action && ( + + )} +
+ ); +} diff --git a/components/cortex/shared/index.ts b/components/cortex/shared/index.ts new file mode 100644 index 0000000..2c808ea --- /dev/null +++ b/components/cortex/shared/index.ts @@ -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'; diff --git a/lib/cortex/entity-extractor.ts b/lib/cortex/entity-extractor.ts index 391bc62..6e607cb 100644 --- a/lib/cortex/entity-extractor.ts +++ b/lib/cortex/entity-extractor.ts @@ -123,6 +123,14 @@ export function extractEntities( return extractCancelAppointmentEntities(trimmedInput, input, referenceDate); case 'reschedule_appointment': return extractRescheduleAppointmentEntities(trimmedInput, input, referenceDate); + // Intake intents (MVP) + case 'intake_navigeer': + return extractIntakeNavigeerEntities(trimmedInput); + case 'intake_status': + case 'risico_query': + case 'diagnose_query': + // These intents use patient context from store, no entity extraction needed + return entities; default: return entities; } @@ -665,3 +673,83 @@ function extractDateLabel(input: string): DateRange['label'] { if (normalized.includes('volgende week')) return 'volgende week'; return 'custom'; } + +// ============================================================================ +// Intake Entity Extraction (MVP) +// ============================================================================ + +/** + * Intake tab targets for navigation. + */ +export type IntakeTab = + | 'contacts' + | 'kindcheck' + | 'risk' + | 'anamnese' + | 'examination' + | 'rom' + | 'diagnosis' + | 'behandeladvies'; + +/** + * Mapping of keywords to intake tabs. + */ +const INTAKE_TAB_KEYWORDS: Record = { + // Risk + 'risico': 'risk', + 'risicotaxatie': 'risk', + 'risicos': 'risk', + // Diagnosis + 'diagnose': 'diagnosis', + 'diagnoses': 'diagnosis', + 'dsm': 'diagnosis', + // Anamnese + 'anamnese': 'anamnese', + 'voorgeschiedenis': 'anamnese', + 'geschiedenis': 'anamnese', + // Contacts + 'contact': 'contacts', + 'contacten': 'contacts', + 'contactmomenten': 'contacts', + // Kindcheck + 'kindcheck': 'kindcheck', + 'kinderen': 'kindcheck', + // Examination + 'onderzoek': 'examination', + 'psychiatrisch': 'examination', + // ROM + 'rom': 'rom', + 'meetinstrumenten': 'rom', + 'vragenlijst': 'rom', + 'vragenlijsten': 'rom', + // Behandeladvies + 'behandeladvies': 'behandeladvies', + 'advies': 'behandeladvies', + 'behandeling': 'behandeladvies', + 'samenvatting': 'behandeladvies', + // Doelen (maps to behandeladvies as it's part of that tab in MVP) + 'doelen': 'behandeladvies', + 'doelstellingen': 'behandeladvies', + // Netwerk (maps to contacts as it's closest in MVP) + 'netwerk': 'contacts', + 'sociaal': 'contacts', +}; + +/** + * Extract entities for intake_navigeer intent. + * Determines which intake tab to navigate to. + */ +function extractIntakeNavigeerEntities(lowerInput: string): ExtractedEntities & { navigationTarget?: IntakeTab } { + const words = lowerInput.split(/\s+/); + + // Find the navigation target + for (const word of words) { + const target = INTAKE_TAB_KEYWORDS[word]; + if (target) { + return { navigationTarget: target } as ExtractedEntities & { navigationTarget?: IntakeTab }; + } + } + + // Default to risk (most common navigation target in intake context) + return { navigationTarget: 'risk' } as ExtractedEntities & { navigationTarget?: IntakeTab }; +} diff --git a/lib/cortex/hooks/index.ts b/lib/cortex/hooks/index.ts index f9077de..c6386e3 100644 --- a/lib/cortex/hooks/index.ts +++ b/lib/cortex/hooks/index.ts @@ -4,5 +4,10 @@ * Reusable hooks for Cortex functionality. */ +// Patient hooks export { usePatientSearch, type PatientSearchResult } from './use-patient-search'; export { usePatientSelection } from './use-patient-selection'; + +// Block hooks (E2) +export { useBlockData } from './use-block-data'; +export { useIntakeContext, type IntakePrefillData } from './use-intake-context'; diff --git a/lib/cortex/hooks/use-block-data.ts b/lib/cortex/hooks/use-block-data.ts new file mode 100644 index 0000000..c963cf9 --- /dev/null +++ b/lib/cortex/hooks/use-block-data.ts @@ -0,0 +1,123 @@ +'use client'; + +/** + * useBlockData Hook + * + * Generic data fetching hook for Cortex blocks. + * Handles loading, error states, and provides refetch capability. + * + * Epic: E2.S1 - Custom Hooks + */ + +import { useState, useEffect, useCallback } from 'react'; +import { useToast } from '@/hooks/use-toast'; +import { safeFetch, getErrorInfo } from '@/lib/cortex/error-handler'; + +// ============================================================================ +// Types +// ============================================================================ + +interface UseBlockDataOptions { + /** API endpoint (relatief pad) */ + endpoint: string; + /** Query parameters */ + params?: Record; + /** Of data moet worden opgehaald */ + enabled?: boolean; + /** Callback bij error */ + onError?: (error: Error) => void; + /** Operatie naam voor error messages */ + operationName?: string; +} + +interface UseBlockDataResult { + /** Opgehaalde data */ + data: T | null; + /** Loading state */ + isLoading: boolean; + /** Error message */ + error: string | null; + /** Refetch functie */ + refetch: () => Promise; +} + +// ============================================================================ +// Hook +// ============================================================================ + +/** + * Generic data fetching hook for Cortex blocks. + * + * @example + * const { data, isLoading, error, refetch } = useBlockData({ + * endpoint: `/api/cortex/intake/${intakeId}/risks`, + * enabled: Boolean(intakeId), + * operationName: 'Risico\'s laden', + * }); + */ +export function useBlockData({ + endpoint, + params, + enabled = true, + onError, + operationName = 'Data laden', +}: UseBlockDataOptions): UseBlockDataResult { + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(enabled); + const [error, setError] = useState(null); + const { toast } = useToast(); + + // Stringify params for dependency comparison + const paramsKey = params ? JSON.stringify(params) : ''; + + const fetchData = useCallback(async () => { + if (!enabled) return; + + setIsLoading(true); + setError(null); + + try { + // Build URL with params + const url = new URL(endpoint, window.location.origin); + if (params) { + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined) { + url.searchParams.set(key, value); + } + }); + } + + const response = await safeFetch(url.toString(), undefined, { + operation: operationName, + }); + + const result = await response.json(); + setData(result); + } catch (err) { + const statusCode = (err as any)?.statusCode; + const errorInfo = getErrorInfo(err, { + operation: operationName, + statusCode, + }); + + setError(errorInfo.description); + onError?.(err as Error); + + toast({ + variant: 'destructive', + title: errorInfo.title, + description: errorInfo.description, + }); + } finally { + setIsLoading(false); + } + }, [endpoint, paramsKey, enabled, onError, operationName, toast]); + + useEffect(() => { + if (enabled) { + fetchData(); + } + }, [fetchData, enabled]); + + return { data, isLoading, error, refetch: fetchData }; +} diff --git a/lib/cortex/hooks/use-intake-context.ts b/lib/cortex/hooks/use-intake-context.ts new file mode 100644 index 0000000..cde410d --- /dev/null +++ b/lib/cortex/hooks/use-intake-context.ts @@ -0,0 +1,85 @@ +'use client'; + +/** + * useIntakeContext Hook + * + * Provides patient and intake context for Cortex blocks. + * Falls back to activePatient from store when prefill is not provided. + * + * Epic: E2.S2 - Custom Hooks + */ + +import { useMemo } from 'react'; +import { useCortexStore } from '@/stores/cortex-store'; +import type { BlockPrefillData } from '@/stores/cortex-store'; +import { formatPatientName } from '@/lib/fhir/patient-mapper'; + +// ============================================================================ +// Types +// ============================================================================ + +/** + * Extended prefill data with intake-specific fields. + * Blocks can pass this to get intake context. + */ +export interface IntakePrefillData extends BlockPrefillData { + /** Intake ID for intake-specific operations */ + intakeId?: string; +} + +interface UseIntakeContextResult { + /** Patient ID (van prefill of activePatient) */ + patientId: string | null; + /** Intake ID (van prefill) */ + intakeId: string | null; + /** Patient naam (voor display) */ + patientName: string | null; + /** Of er patient context is */ + hasPatientContext: boolean; + /** Of er intake context is */ + hasIntakeContext: boolean; +} + +// ============================================================================ +// Hook +// ============================================================================ + +/** + * Hook for getting patient and intake context in Cortex blocks. + * + * Priority: + * 1. Explicit prefill data (from intent classification) + * 2. activePatient from store (fallback) + * + * @example + * const { patientId, intakeId, hasPatientContext } = useIntakeContext(prefill); + * + * if (!hasPatientContext) { + * return ; + * } + */ +export function useIntakeContext( + prefill?: IntakePrefillData +): UseIntakeContextResult { + const { activePatient } = useCortexStore(); + + return useMemo(() => { + // Patient context: prefill takes priority, then activePatient + const patientId = prefill?.patientId || activePatient?.id || null; + const patientName = + prefill?.patientName || + (activePatient ? formatPatientName(activePatient) : null); + + // Intake context: only from prefill for now + // TODO: Add activeIntake to cortex-store for persistent intake context + const intakeId = prefill?.intakeId || null; + + return { + patientId, + intakeId, + patientName, + hasPatientContext: Boolean(patientId), + hasIntakeContext: Boolean(intakeId), + }; + }, [prefill, activePatient]); +} diff --git a/lib/cortex/intent-classifier.ts b/lib/cortex/intent-classifier.ts index adbb03a..1436c1b 100644 --- a/lib/cortex/intent-classifier.ts +++ b/lib/cortex/intent-classifier.ts @@ -152,6 +152,60 @@ const INTENT_PATTERNS: Record, PatternConfig[]> { pattern: /^(verzet|verplaats)\s+\d{1,2}[:.]\d{2}\b/i, weight: 0.9 }, { pattern: /^verzet\s+\w+\s+naar\b/i, weight: 0.85 }, // "verzet jan naar dinsdag" ], + + // Intake intents (MVP) + intake_status: [ + // Exact commands - voortgang/status check + { pattern: /^(wat\s+)?moet\s+ik\s+nog\s+(doen|invullen)/i, weight: 1.0 }, + { pattern: /^is\s+(de\s+)?intake\s+compleet/i, weight: 0.95 }, + { pattern: /^intake\s+status\b/i, weight: 1.0 }, + { pattern: /^voortgang\s+(intake|invullen)\b/i, weight: 0.95 }, + { pattern: /^welke\s+(velden|onderdelen)\s+(missen|ontbreken)/i, weight: 0.9 }, + { pattern: /^(hoeveel|wat)\s+(is|staat)\s+er\s+(nog\s+)?open\b/i, weight: 0.9 }, + { pattern: /^status\s+(intake|invullen)\b/i, weight: 0.95 }, + { pattern: /^checklist\s+intake\b/i, weight: 0.85 }, + ], + + intake_navigeer: [ + // Navigation to specific intake tabs/sections + { pattern: /^ga\s+naar\s+(de\s+)?(risico|risicotaxatie)/i, weight: 1.0 }, + { pattern: /^ga\s+naar\s+(de\s+)?diagnose[ns]?/i, weight: 1.0 }, + { pattern: /^ga\s+naar\s+(de\s+)?anamnese/i, weight: 1.0 }, + { pattern: /^ga\s+naar\s+(de\s+)?behandelgeschiedenis/i, weight: 1.0 }, + { pattern: /^ga\s+naar\s+(de\s+)?medicatie/i, weight: 1.0 }, + { pattern: /^ga\s+naar\s+(de\s+)?meetinstrumenten/i, weight: 1.0 }, + { pattern: /^ga\s+naar\s+(het\s+)?netwerk/i, weight: 1.0 }, + { pattern: /^ga\s+naar\s+(de\s+)?doelen/i, weight: 1.0 }, + { pattern: /^ga\s+naar\s+(de\s+)?samenvatting/i, weight: 1.0 }, + // Open patterns + { pattern: /^open\s+(de\s+)?(risico|diagnose|anamnese|medicatie|netwerk|doelen|samenvatting)/i, weight: 0.95 }, + // Show patterns + { pattern: /^(toon|laat\s+zien)\s+(de\s+)?(risico|diagnose|anamnese|medicatie|netwerk|doelen|samenvatting)/i, weight: 0.9 }, + ], + + risico_query: [ + // Exact commands - risk information + { pattern: /^(wat\s+zijn\s+)?(de\s+)?risico'?s/i, weight: 1.0 }, + { pattern: /^risicotaxatie\b/i, weight: 1.0 }, + { pattern: /^welke\s+risico'?s\s+(zijn|hebben)/i, weight: 0.95 }, + { pattern: /^(is\s+er\s+)?(suïcide|suicide)\s*risico/i, weight: 1.0 }, + { pattern: /^(wat\s+is\s+)?(de\s+)?(sui[cï]daliteit|zelfbeschadiging)/i, weight: 0.95 }, + { pattern: /^risico'?s\s+(van|bij|voor)\s+\w+/i, weight: 0.9 }, + { pattern: /^toon\s+(de\s+)?risico'?s/i, weight: 0.9 }, + { pattern: /^geef\s+(een\s+)?(overzicht|samenvatting)\s+(van\s+)?(de\s+)?risico'?s/i, weight: 0.85 }, + ], + + diagnose_query: [ + // Exact commands - diagnosis information + { pattern: /^(welke\s+)?diagnose[ns]?(\s+heeft)?/i, weight: 1.0 }, + { pattern: /^dsm[- ]?(5|iv|diagnose)/i, weight: 1.0 }, + { pattern: /^(wat\s+is\s+)?(de\s+)?hoofddiagnose/i, weight: 1.0 }, + { pattern: /^(wat\s+zijn\s+)?(de\s+)?nevendiagnose[ns]?/i, weight: 0.95 }, + { pattern: /^toon\s+(de\s+)?diagnose[ns]?/i, weight: 0.9 }, + { pattern: /^diagnose[ns]?\s+(van|bij|voor)\s+\w+/i, weight: 0.9 }, + { pattern: /^geef\s+(een\s+)?(overzicht|samenvatting)\s+(van\s+)?(de\s+)?diagnose[ns]?/i, weight: 0.85 }, + { pattern: /^(is\s+er\s+)?(een\s+)?persoonlijkheidsstoornis/i, weight: 0.9 }, + ], }; // Help patterns (separate, always check) diff --git a/lib/cortex/intent-labels.ts b/lib/cortex/intent-labels.ts index 88b38cd..d643ef0 100644 --- a/lib/cortex/intent-labels.ts +++ b/lib/cortex/intent-labels.ts @@ -20,6 +20,11 @@ export const INTENT_LABELS: Record = { create_appointment: 'Afspraak maken', cancel_appointment: 'Afspraak annuleren', reschedule_appointment: 'Afspraak verzetten', + // Intake intents (MVP) + intake_status: 'Intake status', + intake_navigeer: 'Naar intake sectie', + risico_query: 'Risicotaxatie', + diagnose_query: 'Diagnoses', unknown: 'Onbekend', }; diff --git a/lib/cortex/reflex-classifier.ts b/lib/cortex/reflex-classifier.ts index afbd7e0..fc86f27 100644 --- a/lib/cortex/reflex-classifier.ts +++ b/lib/cortex/reflex-classifier.ts @@ -158,6 +158,60 @@ const INTENT_PATTERNS: Record, PatternConfig[]> { pattern: /^(verzet|verplaats)\s+\d{1,2}[:.]\d{2}\b/i, weight: 0.9 }, { pattern: /^verzet\s+\w+\s+naar\b/i, weight: 0.85 }, ], + + // ========================================================================= + // Intake intents (MVP) + // ========================================================================= + + intake_status: [ + // "Wat moet ik nog doen?" patterns + { pattern: /^(wat\s+)?moet\s+ik\s+nog\s+(doen|invullen)/i, weight: 1.0 }, + { pattern: /^is\s+(de\s+)?intake\s+compleet/i, weight: 0.95 }, + { pattern: /^intake\s+(checklist|status|voortgang)/i, weight: 0.95 }, + { pattern: /^welke\s+secties\s+(zijn|nog)/i, weight: 0.85 }, + { pattern: /^wat\s+is\s+(de\s+)?(intake\s+)?status/i, weight: 0.9 }, + { pattern: /^status\s+(van\s+)?(de\s+)?intake/i, weight: 0.9 }, + { pattern: /^intake\s+overzicht/i, weight: 0.85 }, + ], + + intake_navigeer: [ + // "Ga naar [sectie]" patterns + { pattern: /^ga\s+naar\s+(de\s+)?(risico|risicotaxatie)/i, weight: 1.0 }, + { pattern: /^ga\s+naar\s+(de\s+)?diagnose/i, weight: 1.0 }, + { pattern: /^ga\s+naar\s+(de\s+)?kindcheck/i, weight: 1.0 }, + { pattern: /^ga\s+naar\s+(de\s+)?anamnese/i, weight: 1.0 }, + { pattern: /^ga\s+naar\s+(de\s+)?behandeladvies/i, weight: 1.0 }, + { pattern: /^ga\s+naar\s+(de\s+)?(rom|vragenlijst)/i, weight: 1.0 }, + { pattern: /^ga\s+naar\s+(de\s+)?onderzoek/i, weight: 1.0 }, + { pattern: /^ga\s+naar\s+(de\s+)?contact/i, weight: 1.0 }, + { pattern: /^ga\s+naar\s+(de\s+)?algemeen/i, weight: 1.0 }, + + // "Open [sectie]" patterns + { pattern: /^open\s+(de\s+)?(risico|diagnose|kindcheck|anamnese)/i, weight: 0.95 }, + + // "Naar [sectie]" patterns (shorter) + { pattern: /^naar\s+(risico|diagnose|kindcheck|anamnese|behandeladvies)/i, weight: 0.9 }, + ], + + risico_query: [ + // "Wat zijn de risico's?" patterns + { pattern: /^(wat\s+zijn\s+)?(de\s+)?risico'?s/i, weight: 1.0 }, + { pattern: /^(toon|show|bekijk)\s+(de\s+)?risico/i, weight: 0.95 }, + { pattern: /^risicotaxatie/i, weight: 0.95 }, + { pattern: /^risico\s+overzicht/i, weight: 0.9 }, + { pattern: /^welke\s+risico'?s/i, weight: 0.9 }, + { pattern: /^risico'?s\s+(van|voor)\s+\w+/i, weight: 0.95 }, + ], + + diagnose_query: [ + // "Welke diagnoses?" patterns + { pattern: /^(welke\s+)?diagnose[ns]?(\s+heeft)?/i, weight: 1.0 }, + { pattern: /^(toon|show|bekijk)\s+(de\s+)?diagnose/i, weight: 0.95 }, + { pattern: /^wat\s+is\s+(de\s+)?diagnose/i, weight: 0.95 }, + { pattern: /^diagnose\s+overzicht/i, weight: 0.9 }, + { pattern: /^diagnose[ns]?\s+(van|voor)\s+\w+/i, weight: 0.95 }, + { pattern: /^icd\s*-?\s*10/i, weight: 0.8 }, + ], }; /** diff --git a/lib/cortex/types.ts b/lib/cortex/types.ts index 548d919..7c36148 100644 --- a/lib/cortex/types.ts +++ b/lib/cortex/types.ts @@ -15,9 +15,16 @@ export type CortexIntent = | 'create_appointment' | 'cancel_appointment' | 'reschedule_appointment' + // Intake intents (MVP) + | 'intake_status' + | 'intake_navigeer' + | 'risico_query' + | 'diagnose_query' | 'unknown'; -export type BlockType = Exclude | 'patient-dashboard'; +export type BlockType = + | Exclude + | 'patient-dashboard'; // Shift types export type ShiftType = 'nacht' | 'ochtend' | 'middag' | 'avond'; @@ -81,6 +88,9 @@ export interface ExtractedEntities { // Legacy fields (for backward compatibility) date?: string; time?: string; + + // Intake navigation (MVP) + navigationTarget?: 'contacts' | 'kindcheck' | 'risk' | 'anamnese' | 'examination' | 'rom' | 'diagnosis' | 'behandeladvies'; } // Block sizes @@ -144,6 +154,25 @@ export const BLOCK_CONFIGS: Record = { size: 'lg', icon: 'LayoutDashboard', }, + // Intake blocks (MVP) + intake_status: { + type: 'intake_status', + title: 'Intake Status', + size: 'md', + icon: 'ClipboardList', + }, + risico_query: { + type: 'risico_query', + title: 'Risicotaxatie', + size: 'md', + icon: 'AlertTriangle', + }, + diagnose_query: { + type: 'diagnose_query', + title: 'Diagnoses', + size: 'md', + icon: 'Stethoscope', + }, }; // Recent action type