diff --git a/app/api/overdracht/[patientId]/route.ts b/app/api/overdracht/[patientId]/route.ts new file mode 100644 index 0000000..e4deba0 --- /dev/null +++ b/app/api/overdracht/[patientId]/route.ts @@ -0,0 +1,181 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { createClient } from '@/lib/auth/server'; +import type { + PatientDetail, + VitalSign, + Report, + RiskAssessment, + Condition, +} from '@/lib/types/overdracht'; +import type { NursingLog } from '@/lib/types/nursing-log'; + +interface RouteParams { + params: Promise<{ patientId: string }>; +} + +export async function GET(request: NextRequest, { params }: RouteParams) { + try { + const { patientId } = await params; + + if (!z.string().uuid().safeParse(patientId).success) { + return NextResponse.json( + { error: 'patientId moet een geldige UUID zijn' }, + { status: 400 } + ); + } + + const supabase = await createClient(); + + // Date calculations + const today = new Date().toISOString().split('T')[0]; + const todayStart = `${today}T00:00:00.000Z`; + const last24h = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + + // Parallel queries for all data + const [ + patientResult, + vitalsResult, + reportsResult, + logsResult, + risksResult, + conditionsResult, + ] = await Promise.all([ + // 1. Patient info + supabase + .from('patients') + .select('id, name_given, name_family, name_prefix, birth_date, gender') + .eq('id', patientId) + .single(), + + // 2. Vitals today + supabase + .from('observations') + .select('id, code_display, value_quantity_value, value_quantity_unit, interpretation_code, effective_datetime') + .eq('patient_id', patientId) + .eq('category', 'vital-signs') + .gte('effective_datetime', todayStart) + .order('effective_datetime', { ascending: false }), + + // 3. Reports last 24h + supabase + .from('reports') + .select('id, type, content, created_at, created_by') + .eq('patient_id', patientId) + .gte('created_at', last24h) + .is('deleted_at', null) + .order('created_at', { ascending: false }), + + // 4. Nursing logs today (all, not just marked) + supabase + .from('nursing_logs') + .select('*') + .eq('patient_id', patientId) + .eq('shift_date', today) + .order('timestamp', { ascending: false }), + + // 5. Risks via intakes + supabase + .from('risk_assessments') + .select('id, risk_type, risk_level, rationale, created_at, intakes!inner(patient_id)') + .eq('intakes.patient_id', patientId) + .in('risk_level', ['laag', 'gemiddeld', 'hoog', 'zeer_hoog']), + + // 6. Active conditions + supabase + .from('conditions') + .select('id, code_display, clinical_status, onset_datetime') + .eq('patient_id', patientId) + .eq('clinical_status', 'active'), + ]); + + // Check if patient exists + if (patientResult.error || !patientResult.data) { + return NextResponse.json( + { error: 'Patiënt niet gevonden' }, + { status: 404 } + ); + } + + // Handle errors for other queries gracefully (return empty arrays) + if (vitalsResult.error) { + console.error('Error fetching vitals:', vitalsResult.error); + } + if (reportsResult.error) { + console.error('Error fetching reports:', reportsResult.error); + } + if (logsResult.error) { + console.error('Error fetching nursing logs:', logsResult.error); + } + if (risksResult.error) { + console.error('Error fetching risks:', risksResult.error); + } + if (conditionsResult.error) { + console.error('Error fetching conditions:', conditionsResult.error); + } + + // Map vitals + const vitals: VitalSign[] = (vitalsResult.data || []).map((v) => ({ + id: v.id, + code_display: v.code_display, + value_quantity_value: v.value_quantity_value, + value_quantity_unit: v.value_quantity_unit, + interpretation_code: v.interpretation_code, + effective_datetime: v.effective_datetime, + })); + + // Map reports + const reports: Report[] = (reportsResult.data || []).map((r) => ({ + id: r.id, + type: r.type, + content: r.content, + created_at: r.created_at, + created_by: r.created_by, + })); + + // Nursing logs (already typed correctly from database) + const nursingLogs: NursingLog[] = logsResult.data || []; + + // Map risks (remove the intakes join data) + const risks: RiskAssessment[] = (risksResult.data || []).map((r) => ({ + id: r.id, + risk_type: r.risk_type, + risk_level: r.risk_level, + rationale: r.rationale, + created_at: r.created_at, + })); + + // Map conditions + const conditions: Condition[] = (conditionsResult.data || []).map((c) => ({ + id: c.id, + code_display: c.code_display, + clinical_status: c.clinical_status, + onset_datetime: c.onset_datetime || undefined, + })); + + // Build response + const response: PatientDetail = { + patient: { + id: patientResult.data.id, + name_given: patientResult.data.name_given, + name_family: patientResult.data.name_family, + name_prefix: patientResult.data.name_prefix || undefined, + birth_date: patientResult.data.birth_date, + gender: patientResult.data.gender, + }, + vitals, + reports, + nursingLogs, + risks, + conditions, + }; + + return NextResponse.json(response); + } catch (error) { + console.error('Unexpected error in GET /api/overdracht/[patientId]:', error); + return NextResponse.json( + { error: 'Onverwachte serverfout' }, + { status: 500 } + ); + } +} diff --git a/app/api/overdracht/generate/route.ts b/app/api/overdracht/generate/route.ts new file mode 100644 index 0000000..2aa318a --- /dev/null +++ b/app/api/overdracht/generate/route.ts @@ -0,0 +1,329 @@ +/** + * Overdracht Generate API + * + * POST /api/overdracht/generate + * Genereert een overdracht samenvatting met Claude AI + */ + +import { createClient } from '@/lib/auth/server'; +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { + OVERDRACHT_SYSTEM_PROMPT, + buildOverdrachtUserPrompt, + calculateAge, + formatPatientName, + type OverdrachtContext, +} from '@/lib/ai/overdracht-prompt'; +import { + GenerateOverdrachtSchema, + type AISamenvatting, + type Aandachtspunt, +} from '@/lib/types/overdracht'; +import type { NursingLog } from '@/lib/types/nursing-log'; + +// Zod schema for AI response validation +const AandachtspuntSchema = z.object({ + tekst: z.string(), + urgent: z.boolean(), + bron: z.object({ + type: z.enum(['observatie', 'rapportage', 'dagnotitie', 'risico']), + id: z.string(), + datum: z.string(), + label: z.string(), + }), +}); + +const AIResponseSchema = z.object({ + samenvatting: z.string(), + aandachtspunten: z.array(AandachtspuntSchema).max(5), + actiepunten: z.array(z.string()).max(3), +}); + +/** + * Load context from database + */ +async function loadOverdrachtContext( + supabase: Awaited>, + patientId: string +): Promise { + const today = new Date().toISOString().split('T')[0]; + const todayStart = `${today}T00:00:00.000Z`; + const last24h = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + + // Parallel queries + const [ + patientResult, + vitalsResult, + reportsResult, + logsResult, + risksResult, + conditionsResult, + ] = await Promise.all([ + supabase + .from('patients') + .select('id, name_given, name_family, name_prefix, birth_date, gender') + .eq('id', patientId) + .single(), + supabase + .from('observations') + .select('id, code_display, value_quantity_value, value_quantity_unit, interpretation_code, effective_datetime') + .eq('patient_id', patientId) + .eq('category', 'vital-signs') + .gte('effective_datetime', todayStart) + .order('effective_datetime', { ascending: false }), + supabase + .from('reports') + .select('id, type, content, created_at, created_by') + .eq('patient_id', patientId) + .gte('created_at', last24h) + .is('deleted_at', null) + .order('created_at', { ascending: false }), + supabase + .from('nursing_logs') + .select('*') + .eq('patient_id', patientId) + .eq('shift_date', today) + .order('timestamp', { ascending: false }), + supabase + .from('risk_assessments') + .select('id, risk_type, risk_level, rationale, created_at, intakes!inner(patient_id)') + .eq('intakes.patient_id', patientId), + supabase + .from('conditions') + .select('id, code_display, clinical_status, onset_datetime') + .eq('patient_id', patientId) + .eq('clinical_status', 'active'), + ]); + + if (patientResult.error || !patientResult.data) { + throw new Error('Patiënt niet gevonden'); + } + + const patient = patientResult.data; + + return { + patientId, + patientName: formatPatientName( + patient.name_given, + patient.name_family, + patient.name_prefix || undefined + ), + age: calculateAge(patient.birth_date), + gender: patient.gender, + conditions: (conditionsResult.data || []).map((c) => ({ + id: c.id, + code_display: c.code_display, + clinical_status: c.clinical_status, + onset_datetime: c.onset_datetime || undefined, + })), + vitals: (vitalsResult.data || []).map((v) => ({ + id: v.id, + code_display: v.code_display, + value_quantity_value: v.value_quantity_value, + value_quantity_unit: v.value_quantity_unit, + interpretation_code: v.interpretation_code, + effective_datetime: v.effective_datetime, + })), + reports: (reportsResult.data || []).map((r) => ({ + id: r.id, + type: r.type, + content: r.content, + created_at: r.created_at, + created_by: r.created_by, + })), + nursingLogs: (logsResult.data || []) as NursingLog[], + risks: (risksResult.data || []).map((r) => ({ + id: r.id, + risk_type: r.risk_type, + risk_level: r.risk_level, + rationale: r.rationale, + created_at: r.created_at, + })), + }; +} + +/** + * Call Claude API + */ +async function callClaudeAPI(context: OverdrachtContext): Promise<{ + samenvatting: string; + aandachtspunten: Aandachtspunt[]; + actiepunten: string[]; +}> { + const apiKey = process.env.ANTHROPIC_API_KEY; + if (!apiKey) { + throw new Error('ANTHROPIC_API_KEY ontbreekt in environment'); + } + + const userPrompt = buildOverdrachtUserPrompt(context); + + const response = await fetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model: 'claude-sonnet-4-20250514', + max_tokens: 2048, + temperature: 0.3, + system: OVERDRACHT_SYSTEM_PROMPT, + messages: [{ role: 'user', content: userPrompt }], + }), + }); + + if (!response.ok) { + const errorBody = await response.text(); + console.error('Claude API error:', errorBody); + throw new Error(`Claude API fout: ${response.status}`); + } + + const data = await response.json(); + const rawText = data?.content?.[0]?.text; + + if (!rawText) { + throw new Error('Geen response van Claude API'); + } + + // Parse JSON from response (handle potential markdown code blocks) + let jsonText = rawText.trim(); + if (jsonText.startsWith('```json')) { + jsonText = jsonText.slice(7); + } + if (jsonText.startsWith('```')) { + jsonText = jsonText.slice(3); + } + if (jsonText.endsWith('```')) { + jsonText = jsonText.slice(0, -3); + } + + const parsed = JSON.parse(jsonText.trim()); + + // Validate with Zod schema + const validated = AIResponseSchema.parse(parsed); + + return validated; +} + +/** + * Log AI event to database + */ +async function logAIEvent( + supabase: Awaited>, + patientId: string, + context: OverdrachtContext, + result: { samenvatting: string; aandachtspunten: Aandachtspunt[]; actiepunten: string[] }, + durationMs: number +) { + try { + await supabase.from('ai_events').insert({ + kind: 'overdracht_generate', + patient_id: patientId, + input_data: { + vitalCount: context.vitals.length, + reportCount: context.reports.length, + logCount: context.nursingLogs.length, + riskCount: context.risks.length, + conditionCount: context.conditions.length, + }, + output_data: { + aandachtspuntenCount: result.aandachtspunten.length, + actiepuntenCount: result.actiepunten.length, + urgentCount: result.aandachtspunten.filter((a) => a.urgent).length, + }, + duration_ms: durationMs, + }); + } catch (error) { + // Log but don't fail the request + console.error('Failed to log AI event:', error); + } +} + +/** + * POST /api/overdracht/generate + */ +export async function POST(request: NextRequest) { + const startTime = Date.now(); + + try { + const body = await request.json(); + + // Validate input + const result = GenerateOverdrachtSchema.safeParse(body); + if (!result.success) { + return NextResponse.json( + { + error: 'Validatiefout', + details: result.error.issues.map((e) => ({ + field: e.path.join('.'), + message: e.message, + })), + }, + { status: 400 } + ); + } + + const { patientId } = result.data; + const supabase = await createClient(); + + // Check auth + const { data: authData } = await supabase.auth.getUser(); + if (!authData?.user) { + return NextResponse.json({ error: 'Niet geautoriseerd' }, { status: 401 }); + } + + // Load context + const context = await loadOverdrachtContext(supabase, patientId); + + // Call Claude API + const aiResult = await callClaudeAPI(context); + + const durationMs = Date.now() - startTime; + + // Log AI event + await logAIEvent(supabase, patientId, context, aiResult, durationMs); + + // Build response + const response: AISamenvatting = { + samenvatting: aiResult.samenvatting, + aandachtspunten: aiResult.aandachtspunten, + actiepunten: aiResult.actiepunten, + generatedAt: new Date().toISOString(), + durationMs, + }; + + return NextResponse.json(response); + } catch (error) { + console.error('Error generating overdracht:', error); + + const errorMessage = error instanceof Error ? error.message : 'Onbekende fout'; + + if (errorMessage.includes('Patiënt niet gevonden')) { + return NextResponse.json({ error: errorMessage }, { status: 404 }); + } + + if (errorMessage.includes('ANTHROPIC_API_KEY')) { + return NextResponse.json( + { error: 'AI service niet geconfigureerd' }, + { status: 503 } + ); + } + + if (errorMessage.includes('Claude API')) { + return NextResponse.json( + { error: 'AI service tijdelijk niet beschikbaar', details: errorMessage }, + { status: 503 } + ); + } + + return NextResponse.json( + { + error: 'Fout bij genereren overdracht', + details: process.env.NODE_ENV === 'development' ? errorMessage : undefined, + }, + { status: 500 } + ); + } +} diff --git a/app/api/overdracht/patients/route.ts b/app/api/overdracht/patients/route.ts new file mode 100644 index 0000000..233def5 --- /dev/null +++ b/app/api/overdracht/patients/route.ts @@ -0,0 +1,206 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/lib/auth/server'; +import type { PatientOverzicht, PatientOverzichtResponse } from '@/lib/types/overdracht'; + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const dateParam = searchParams.get('date'); + + // Use provided date or today + const targetDate = dateParam || new Date().toISOString().split('T')[0]; + + // Validate date format + if (!/^\d{4}-\d{2}-\d{2}$/.test(targetDate)) { + return NextResponse.json( + { error: 'date moet in YYYY-MM-DD formaat zijn' }, + { status: 400 } + ); + } + + const supabase = await createClient(); + + // Get start and end of day for date filtering + const dayStart = `${targetDate}T00:00:00.000Z`; + const dayEnd = `${targetDate}T23:59:59.999Z`; + + // 1. Get patients with encounters today + const { data: encounterData, error: encounterError } = await supabase + .from('encounters') + .select(` + patient_id, + patients!inner ( + id, + name_given, + name_family, + birth_date, + gender + ) + `) + .gte('period_start', dayStart) + .lte('period_start', dayEnd) + .in('status', ['planned', 'in-progress', 'completed']); + + if (encounterError) { + console.error('Error fetching encounters:', encounterError); + return NextResponse.json( + { error: 'Fout bij ophalen patiënten', details: encounterError.message }, + { status: 500 } + ); + } + + // Deduplicate patients (one patient may have multiple encounters) + const patientMap = new Map(); + + for (const encounter of encounterData || []) { + const patient = encounter.patients as unknown as { + id: string; + name_given: string[]; + name_family: string; + birth_date: string; + gender: string; + }; + if (patient && !patientMap.has(patient.id)) { + patientMap.set(patient.id, patient); + } + } + + const patientIds = Array.from(patientMap.keys()); + + if (patientIds.length === 0) { + const response: PatientOverzichtResponse = { + patients: [], + total: 0, + date: targetDate, + }; + return NextResponse.json(response); + } + + // 2. Get alert counts in parallel + const [ + { data: risksData }, + { data: vitalsData }, + { data: logsData }, + ] = await Promise.all([ + // High risk assessments (via intakes) + supabase + .from('risk_assessments') + .select('id, intakes!inner(patient_id)') + .in('intakes.patient_id', patientIds) + .in('risk_level', ['hoog', 'zeer_hoog']), + + // Abnormal vitals today + supabase + .from('observations') + .select('id, patient_id, interpretation_code') + .in('patient_id', patientIds) + .eq('category', 'vital-signs') + .gte('effective_datetime', dayStart) + .lte('effective_datetime', dayEnd) + .in('interpretation_code', ['H', 'L', 'HH', 'LL']), + + // Marked nursing logs for handover + supabase + .from('nursing_logs') + .select('id, patient_id') + .in('patient_id', patientIds) + .eq('shift_date', targetDate) + .eq('include_in_handover', true), + ]); + + // Count alerts per patient + const alertCounts = new Map(); + + // Initialize all patients with zero counts + for (const patientId of patientIds) { + alertCounts.set(patientId, { + high_risk_count: 0, + abnormal_vitals_count: 0, + marked_logs_count: 0, + }); + } + + // Count high risks + for (const risk of risksData || []) { + const intake = risk.intakes as unknown as { patient_id: string }; + if (intake?.patient_id) { + const counts = alertCounts.get(intake.patient_id); + if (counts) counts.high_risk_count++; + } + } + + // Count abnormal vitals + for (const vital of vitalsData || []) { + if (vital.patient_id) { + const counts = alertCounts.get(vital.patient_id); + if (counts) counts.abnormal_vitals_count++; + } + } + + // Count marked logs + for (const log of logsData || []) { + if (log.patient_id) { + const counts = alertCounts.get(log.patient_id); + if (counts) counts.marked_logs_count++; + } + } + + // 3. Build response + const patients: PatientOverzicht[] = Array.from(patientMap.values()).map( + (patient) => { + const alerts = alertCounts.get(patient.id) || { + high_risk_count: 0, + abnormal_vitals_count: 0, + marked_logs_count: 0, + }; + + return { + id: patient.id, + name_given: patient.name_given, + name_family: patient.name_family, + birth_date: patient.birth_date, + gender: patient.gender, + alerts: { + ...alerts, + total: + alerts.high_risk_count + + alerts.abnormal_vitals_count + + alerts.marked_logs_count, + }, + }; + } + ); + + // Sort by total alerts (descending), then by name + patients.sort((a, b) => { + if (b.alerts.total !== a.alerts.total) { + return b.alerts.total - a.alerts.total; + } + return a.name_family.localeCompare(b.name_family); + }); + + const response: PatientOverzichtResponse = { + patients, + total: patients.length, + date: targetDate, + }; + + return NextResponse.json(response); + } catch (error) { + console.error('Unexpected error in GET /api/overdracht/patients:', error); + return NextResponse.json( + { error: 'Onverwachte serverfout' }, + { status: 500 } + ); + } +} diff --git a/app/epd/dagregistratie/[patientId]/components/log-form.tsx b/app/epd/dagregistratie/[patientId]/components/log-form.tsx new file mode 100644 index 0000000..fb7ef7c --- /dev/null +++ b/app/epd/dagregistratie/[patientId]/components/log-form.tsx @@ -0,0 +1,231 @@ +'use client'; + +/** + * LogForm Component + * E3.S2: Quick entry form met categorie, tijd, tekst en overdracht checkbox + */ + +import { useState, useTransition } from 'react'; +import { format } from 'date-fns'; +import { + Loader2, + Plus, + Pill, + Utensils, + User, + AlertTriangle, + FileText, +} from 'lucide-react'; +import { + NURSING_LOG_CATEGORIES, + CATEGORY_CONFIG, + type NursingLogCategory, +} from '@/lib/types/nursing-log'; + +interface LogFormProps { + patientId: string; + onSuccess: () => void; +} + +// Icon mapping +const CATEGORY_ICONS: Record> = { + medicatie: Pill, + adl: Utensils, + gedrag: User, + incident: AlertTriangle, + observatie: FileText, +}; + +export function LogForm({ patientId, onSuccess }: LogFormProps) { + const [category, setCategory] = useState('observatie'); + const [content, setContent] = useState(''); + const [time, setTime] = useState(format(new Date(), 'HH:mm')); + const [includeInHandover, setIncludeInHandover] = useState(false); + const [error, setError] = useState(null); + const [isPending, startTransition] = useTransition(); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + if (!content.trim()) { + setError('Vul een notitie in'); + return; + } + + if (content.length > 500) { + setError('Notitie mag maximaal 500 karakters bevatten'); + return; + } + + setError(null); + + // Build timestamp from date and time + const today = new Date(); + const [hours, minutes] = time.split(':').map(Number); + today.setHours(hours, minutes, 0, 0); + const timestamp = today.toISOString(); + + startTransition(async () => { + try { + const response = await fetch('/api/nursing-logs', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + patient_id: patientId, + category, + content: content.trim(), + timestamp, + include_in_handover: includeInHandover, + }), + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || 'Opslaan mislukt'); + } + + // Reset form + setContent(''); + setTime(format(new Date(), 'HH:mm')); + setIncludeInHandover(false); + setCategory('observatie'); + + onSuccess(); + } catch (err) { + console.error('Failed to create log:', err); + setError(err instanceof Error ? err.message : 'Opslaan mislukt'); + } + }); + }; + + const charactersLeft = 500 - content.length; + const selectedConfig = CATEGORY_CONFIG[category]; + + return ( +
+
+

Nieuwe notitie

+
+ +
+ {/* Category Selection */} +
+ +
+ {NURSING_LOG_CATEGORIES.map((cat) => { + const config = CATEGORY_CONFIG[cat]; + const Icon = CATEGORY_ICONS[cat]; + const isSelected = category === cat; + + return ( + + ); + })} +
+
+ + {/* Time Input */} +
+ + setTime(e.target.value)} + className="w-full sm:w-32 rounded-lg border border-slate-200 px-3 py-2 text-sm focus:border-teal-500 focus:ring-2 focus:ring-teal-100 outline-none" + /> +
+ + {/* Content Textarea */} +
+ +