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:
183
app/api/cortex/intake/diagnose/route.ts
Normal file
183
app/api/cortex/intake/diagnose/route.ts
Normal file
@@ -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<ClinicalStatus, string> = {
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
201
app/api/cortex/intake/risico/route.ts
Normal file
201
app/api/cortex/intake/risico/route.ts
Normal file
@@ -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<RiskLevel, number> = {
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
225
app/api/cortex/intake/status/route.ts
Normal file
225
app/api/cortex/intake/status/route.ts
Normal file
@@ -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<string, number> = {
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
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>
|
||||
|
||||
@@ -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 <ZoekenBlock prefill={prefill} />;
|
||||
case 'overdracht':
|
||||
return <OverdrachtBlock prefill={prefill} />;
|
||||
// Intake blocks (MVP)
|
||||
case 'intake_status':
|
||||
return <IntakeStatusBlock prefill={prefill} />;
|
||||
case 'risico_query':
|
||||
return <RisicoBlock prefill={prefill} />;
|
||||
case 'diagnose_query':
|
||||
return <DiagnoseBlock prefill={prefill} />;
|
||||
case 'fallback':
|
||||
return <FallbackPicker originalInput={prefill.content} />;
|
||||
default:
|
||||
|
||||
@@ -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<CortexIntent, { icon: typeof FileText; color: string; label: string }> = {
|
||||
dagnotitie: { icon: FileText, color: 'text-blue-600 bg-blue-50 border border-blue-200', label: 'Notitie' },
|
||||
@@ -18,6 +21,11 @@ const INTENT_CONFIG: Record<CortexIntent, { icon: typeof FileText; color: string
|
||||
create_appointment: { icon: Plus, color: 'text-green-600 bg-green-50 border border-green-200', label: 'Afspraak' },
|
||||
cancel_appointment: { icon: X, color: 'text-red-600 bg-red-50 border border-red-200', label: 'Annuleren' },
|
||||
reschedule_appointment: { icon: Clock, color: 'text-amber-600 bg-amber-50 border border-amber-200', label: 'Verzetten' },
|
||||
// Intake intents (MVP)
|
||||
intake_status: { icon: ClipboardList, color: 'text-cyan-600 bg-cyan-50 border border-cyan-200', label: 'Status' },
|
||||
intake_navigeer: { icon: Navigation, color: 'text-indigo-600 bg-indigo-50 border border-indigo-200', label: 'Navigeer' },
|
||||
risico_query: { icon: AlertTriangle, color: 'text-orange-600 bg-orange-50 border border-orange-200', label: 'Risico' },
|
||||
diagnose_query: { icon: Stethoscope, color: 'text-rose-600 bg-rose-50 border border-rose-200', label: 'Diagnose' },
|
||||
unknown: { icon: HelpCircle, color: 'text-slate-600 bg-slate-50 border border-slate-200', label: 'Actie' },
|
||||
};
|
||||
|
||||
@@ -34,18 +42,27 @@ function formatRelativeTime(date: Date): string {
|
||||
}
|
||||
|
||||
export function RecentStrip() {
|
||||
const { recentActions, setInputValue, openBlock } = useCortexStore();
|
||||
const { recentActions, setInputValue, openBlock, activePatient } = useCortexStore();
|
||||
|
||||
const handleActionClick = (action: typeof recentActions[0]) => {
|
||||
// Set the input to repeat the action
|
||||
setInputValue(action.label);
|
||||
|
||||
// If it's a known intent, open the block directly
|
||||
if (action.intent !== 'unknown') {
|
||||
// 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 (
|
||||
|
||||
103
components/cortex/shared/block-footer.tsx
Normal file
103
components/cortex/shared/block-footer.tsx
Normal file
@@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between',
|
||||
'pt-4 mt-4 border-t border-slate-200',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Secondary Action (left) */}
|
||||
{secondaryAction ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={secondaryAction.onClick}
|
||||
disabled={secondaryAction.disabled}
|
||||
>
|
||||
{secondaryAction.icon && (
|
||||
<secondaryAction.icon className="h-4 w-4 mr-1.5" />
|
||||
)}
|
||||
{secondaryAction.label}
|
||||
</Button>
|
||||
) : (
|
||||
<div /> // Spacer
|
||||
)}
|
||||
|
||||
{/* Primary Action (right) */}
|
||||
{primaryAction && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={primaryAction.onClick}
|
||||
disabled={primaryAction.loading || primaryAction.disabled}
|
||||
>
|
||||
{primaryAction.loading ? (
|
||||
<Loader2 className="h-4 w-4 mr-1.5 animate-spin" />
|
||||
) : primaryAction.icon ? (
|
||||
<primaryAction.icon className="h-4 w-4 mr-1.5" />
|
||||
) : null}
|
||||
{primaryAction.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
128
components/cortex/shared/block-item.tsx
Normal file
128
components/cortex/shared/block-item.tsx
Normal file
@@ -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<BadgeVariant, string> = {
|
||||
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 (
|
||||
<Component
|
||||
type={isClickable ? 'button' : undefined}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'flex items-center justify-between p-3 rounded-lg',
|
||||
'bg-slate-50 border border-slate-200',
|
||||
'w-full text-left',
|
||||
isClickable && [
|
||||
'cursor-pointer',
|
||||
'hover:bg-slate-100 hover:border-slate-300',
|
||||
'transition-colors',
|
||||
],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Content */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-slate-900 truncate">{title}</p>
|
||||
{subtitle && (
|
||||
<p className="text-xs text-slate-500 mt-0.5 truncate">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Badge */}
|
||||
{badge && (
|
||||
<span
|
||||
className={cn(
|
||||
'ml-3 flex-shrink-0',
|
||||
'px-2 py-0.5 rounded-full',
|
||||
'text-xs font-medium border',
|
||||
BADGE_STYLES[badge.variant]
|
||||
)}
|
||||
>
|
||||
{badge.label}
|
||||
</span>
|
||||
)}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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';
|
||||
}
|
||||
}
|
||||
62
components/cortex/shared/block-section.tsx
Normal file
62
components/cortex/shared/block-section.tsx
Normal file
@@ -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 (
|
||||
<section
|
||||
className={cn(
|
||||
'bg-white rounded-lg border border-slate-200 p-4',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Icon className={cn('h-4 w-4', iconColor)} />
|
||||
<h3 className="text-sm font-medium text-slate-700">{title}</h3>
|
||||
{count !== undefined && (
|
||||
<span className="text-xs text-slate-500">({count})</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
136
components/cortex/shared/block-states.tsx
Normal file
136
components/cortex/shared/block-states.tsx
Normal file
@@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center py-12',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Loader2 className="h-6 w-6 animate-spin text-slate-400 mb-2" />
|
||||
<span className="text-sm text-slate-500">{message}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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 (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center py-8 text-center',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<AlertCircle className="h-8 w-8 text-red-500 mb-2" />
|
||||
<p className="text-sm text-red-700 mb-3 max-w-xs">{message}</p>
|
||||
{onRetry && (
|
||||
<Button variant="outline" size="sm" onClick={onRetry}>
|
||||
<RefreshCw className="h-4 w-4 mr-1.5" />
|
||||
Opnieuw proberen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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 (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center py-8 text-center',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Icon className="h-8 w-8 text-slate-300 mb-2" />
|
||||
<p className="text-sm text-slate-500 mb-3">{message}</p>
|
||||
{action && (
|
||||
<Button variant="outline" size="sm" onClick={action.onClick}>
|
||||
{action.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
components/cortex/shared/index.ts
Normal file
21
components/cortex/shared/index.ts
Normal file
@@ -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';
|
||||
@@ -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<string, IntakeTab> = {
|
||||
// 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 };
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
123
lib/cortex/hooks/use-block-data.ts
Normal file
123
lib/cortex/hooks/use-block-data.ts
Normal file
@@ -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<T> {
|
||||
/** API endpoint (relatief pad) */
|
||||
endpoint: string;
|
||||
/** Query parameters */
|
||||
params?: Record<string, string | undefined>;
|
||||
/** Of data moet worden opgehaald */
|
||||
enabled?: boolean;
|
||||
/** Callback bij error */
|
||||
onError?: (error: Error) => void;
|
||||
/** Operatie naam voor error messages */
|
||||
operationName?: string;
|
||||
}
|
||||
|
||||
interface UseBlockDataResult<T> {
|
||||
/** Opgehaalde data */
|
||||
data: T | null;
|
||||
/** Loading state */
|
||||
isLoading: boolean;
|
||||
/** Error message */
|
||||
error: string | null;
|
||||
/** Refetch functie */
|
||||
refetch: () => Promise<void>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hook
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Generic data fetching hook for Cortex blocks.
|
||||
*
|
||||
* @example
|
||||
* const { data, isLoading, error, refetch } = useBlockData<RiskData>({
|
||||
* endpoint: `/api/cortex/intake/${intakeId}/risks`,
|
||||
* enabled: Boolean(intakeId),
|
||||
* operationName: 'Risico\'s laden',
|
||||
* });
|
||||
*/
|
||||
export function useBlockData<T>({
|
||||
endpoint,
|
||||
params,
|
||||
enabled = true,
|
||||
onError,
|
||||
operationName = 'Data laden',
|
||||
}: UseBlockDataOptions<T>): UseBlockDataResult<T> {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(enabled);
|
||||
const [error, setError] = useState<string | null>(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 };
|
||||
}
|
||||
85
lib/cortex/hooks/use-intake-context.ts
Normal file
85
lib/cortex/hooks/use-intake-context.ts
Normal file
@@ -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 <BlockEmpty icon={User} message="Selecteer eerst een patiënt" />;
|
||||
* }
|
||||
*/
|
||||
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]);
|
||||
}
|
||||
@@ -152,6 +152,60 @@ const INTENT_PATTERNS: Record<Exclude<CortexIntent, 'unknown'>, 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)
|
||||
|
||||
@@ -20,6 +20,11 @@ export const INTENT_LABELS: Record<CortexIntent, string> = {
|
||||
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',
|
||||
};
|
||||
|
||||
|
||||
@@ -158,6 +158,60 @@ const INTENT_PATTERNS: Record<Exclude<CortexIntent, 'unknown'>, 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 },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<CortexIntent, 'unknown'> | 'patient-dashboard';
|
||||
export type BlockType =
|
||||
| Exclude<CortexIntent, 'unknown' | 'intake_navigeer'>
|
||||
| '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<BlockType, BlockConfig> = {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user