diff --git a/app/api/cortex/noshow/cancel/route.ts b/app/api/cortex/noshow/cancel/route.ts new file mode 100644 index 0000000..70a70b5 --- /dev/null +++ b/app/api/cortex/noshow/cancel/route.ts @@ -0,0 +1,51 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { createClient } from '@/lib/auth/server'; +import { isMockAppointment } from '@/lib/cortex/mock-data/noshow'; + +const CancelNoShowSchema = z.object({ + appointmentId: z.string().min(1, 'appointmentId is verplicht'), + patientId: z.string().min(1, 'patientId is verplicht'), +}); + +export async function POST(request: NextRequest) { + const supabase = await createClient(); + const { data: { user }, error: authError } = await supabase.auth.getUser(); + if (authError || !user) { + return NextResponse.json({ error: 'Niet geautoriseerd' }, { status: 401 }); + } + + let body: z.infer; + try { + body = CancelNoShowSchema.parse(await request.json()); + } catch { + return NextResponse.json({ error: 'Ongeldige invoer' }, { status: 400 }); + } + + const { appointmentId, patientId } = body; + + // Mock path — demo flow + if (isMockAppointment(appointmentId)) { + return NextResponse.json({ + success: true, + appointmentId, + newStatus: 'cancelled_no_show', + message: 'Afspraak geregistreerd als no show (demo)', + }); + } + + // Productie path — 'cancelled' is de dichtstbijzijnde geldige DB status + // In productie zou je een aparte no_show kolom of notitieveld gebruiken + const { error } = await supabase + .from('encounters') + .update({ status: 'cancelled' as 'cancelled' }) + .eq('id', appointmentId) + .eq('patient_id', patientId); + + if (error) { + console.error('[noshow/cancel] DB error:', error); + return NextResponse.json({ error: 'Annuleren mislukt' }, { status: 500 }); + } + + return NextResponse.json({ success: true, appointmentId, newStatus: 'cancelled_no_show' }); +} diff --git a/app/api/cortex/noshow/context/route.ts b/app/api/cortex/noshow/context/route.ts new file mode 100644 index 0000000..c3ce687 --- /dev/null +++ b/app/api/cortex/noshow/context/route.ts @@ -0,0 +1,62 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/lib/auth/server'; +import { isMockPatient, MOCK_CONCEPT_BRIEF } from '@/lib/cortex/mock-data/noshow'; + +export async function GET(request: NextRequest) { + const supabase = await createClient(); + const { data: { user }, error: authError } = await supabase.auth.getUser(); + if (authError || !user) { + return NextResponse.json({ error: 'Niet geautoriseerd' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const patientId = searchParams.get('patientId'); + + if (!patientId) { + return NextResponse.json({ error: 'patientId is verplicht' }, { status: 400 }); + } + + // Mock path — demo flow + if (isMockPatient(patientId)) { + return NextResponse.json({ + hasConceptBrief: true, + document: { + id: MOCK_CONCEPT_BRIEF.id, + title: MOCK_CONCEPT_BRIEF.title, + content: MOCK_CONCEPT_BRIEF.content, + type: MOCK_CONCEPT_BRIEF.type, + createdAt: MOCK_CONCEPT_BRIEF.createdAt, + }, + }); + } + + // Productie path + const { data, error } = await supabase + .from('reports') + .select('id, content, structured_data, type, created_at') + .eq('patient_id', patientId) + .eq('type', 'huisartsbrief') + .is('deleted_at', null) + .order('created_at', { ascending: false }) + .limit(1) + .single(); + + if (error || !data) { + return NextResponse.json({ hasConceptBrief: false }); + } + + const content = (data.structured_data as Record)?.content as string + ?? data.content + ?? ''; + + return NextResponse.json({ + hasConceptBrief: true, + document: { + id: data.id, + title: 'Huisartsbrief', + content, + type: data.type, + createdAt: data.created_at, + }, + }); +} diff --git a/app/api/cortex/noshow/dispatch/route.ts b/app/api/cortex/noshow/dispatch/route.ts new file mode 100644 index 0000000..6f73895 --- /dev/null +++ b/app/api/cortex/noshow/dispatch/route.ts @@ -0,0 +1,54 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { createClient } from '@/lib/auth/server'; +import { isMockDocument } from '@/lib/cortex/mock-data/noshow'; + +const DispatchSchema = z.object({ + documentId: z.string().min(1), + finalContent: z.string().min(1, 'Definitieve inhoud is verplicht'), +}); + +export async function PATCH(request: NextRequest) { + const supabase = await createClient(); + const { data: { user }, error: authError } = await supabase.auth.getUser(); + if (authError || !user) { + return NextResponse.json({ error: 'Niet geautoriseerd' }, { status: 401 }); + } + + let body: z.infer; + try { + body = DispatchSchema.parse(await request.json()); + } catch { + return NextResponse.json({ error: 'Ongeldige invoer' }, { status: 400 }); + } + + const { documentId, finalContent } = body; + + // Mock path — demo flow + if (isMockDocument(documentId)) { + return NextResponse.json({ + success: true, + documentId, + newStatus: 'ready_for_dispatch', + message: 'Brief verzendklaar gemaakt (demo)', + }); + } + + // Productie path + const { error } = await supabase + .from('reports') + .update({ + status: 'ready_for_dispatch', + structured_data: { content: finalContent }, + updated_at: new Date().toISOString(), + }) + .eq('id', documentId) + .eq('user_id', user.id); + + if (error) { + console.error('[noshow/dispatch] DB error:', error); + return NextResponse.json({ error: 'Opslaan mislukt' }, { status: 500 }); + } + + return NextResponse.json({ success: true, documentId, newStatus: 'ready_for_dispatch' }); +} diff --git a/app/api/cortex/noshow/rescript/route.ts b/app/api/cortex/noshow/rescript/route.ts new file mode 100644 index 0000000..6bf85b1 --- /dev/null +++ b/app/api/cortex/noshow/rescript/route.ts @@ -0,0 +1,90 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { createClient } from '@/lib/auth/server'; + +const RescriptSchema = z.object({ + documentId: z.string().min(1), + originalContent: z.string().min(1, 'Originele inhoud is verplicht'), + patientId: z.string().min(1), +}); + +const RESCRIPT_SYSTEM_PROMPT = `Je bent een klinisch schrijver in een GGZ instelling. +Je taak is een bestaande conceptbrief aanpassen zodat de no-show van de patiënt +professioneel en contextbewust is verwerkt. + +Regels: +- Integreer de no-show in de lopende tekst — voeg het NIET als losse zin achteraan toe +- Gebruik formele GGZ-briefstijl: "de patiënt is helaas niet verschenen op het geplande consult" +- Bewaar alle bestaande informatie in de brief volledig +- Voeg een zin toe over het verzetten van het vervolgcontact +- De brief moet leesbaar en coherent blijven +- Reageer UITSLUITEND met de herschreven brieftekst — geen uitleg, geen inleiding`; + +export async function POST(request: NextRequest) { + const supabase = await createClient(); + const { data: { user }, error: authError } = await supabase.auth.getUser(); + if (authError || !user) { + return NextResponse.json({ error: 'Niet geautoriseerd' }, { status: 401 }); + } + + let body: z.infer; + try { + body = RescriptSchema.parse(await request.json()); + } catch { + return NextResponse.json({ error: 'Ongeldige invoer' }, { status: 400 }); + } + + const { documentId, originalContent } = body; + + const apiKey = process.env.ANTHROPIC_API_KEY; + if (!apiKey) { + return NextResponse.json({ error: 'API key ontbreekt' }, { status: 500 }); + } + + try { + 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-haiku-4-5-20251001', + max_tokens: 1500, + system: RESCRIPT_SYSTEM_PROMPT, + messages: [ + { + role: 'user', + content: `Herschrijf de volgende conceptbrief zodat de no-show van vandaag professioneel is verwerkt:\n\n${originalContent}`, + }, + ], + }), + }); + + if (!response.ok) { + throw new Error(`Anthropic API fout: ${response.status}`); + } + + const data = await response.json(); + const rescriptedContent = data.content?.[0]?.text ?? originalContent; + + return NextResponse.json({ + success: true, + documentId, + rescriptedContent, + originalContent, + }); + } catch (error) { + console.error('[noshow/rescript] LLM error:', error); + + // Graceful degradation: originele inhoud teruggeven + return NextResponse.json({ + success: false, + documentId, + rescriptedContent: originalContent, + originalContent, + warning: 'AI herschrijving mislukt — originele tekst wordt getoond', + }); + } +} diff --git a/components/cortex/blocks/noshow-document-block.tsx b/components/cortex/blocks/noshow-document-block.tsx new file mode 100644 index 0000000..d9ed9e4 --- /dev/null +++ b/components/cortex/blocks/noshow-document-block.tsx @@ -0,0 +1,140 @@ +'use client'; + +import { useState } from 'react'; +import { FileText, ChevronDown, ChevronUp } from 'lucide-react'; +import { Textarea } from '@/components/ui/textarea'; +import { Button } from '@/components/ui/button'; +import { useCortexStore } from '@/stores/cortex-store'; +import { cn } from '@/lib/utils'; + +interface NoShowDocumentPrefill { + documentId: string; + content: string; + title: string; + originalContent?: string; + rescriptWarning?: string; +} + +interface NoShowDocumentBlockProps { + prefill: NoShowDocumentPrefill; +} + +export function NoShowDocumentBlock({ prefill }: NoShowDocumentBlockProps) { + const [content, setContent] = useState(prefill.content ?? ''); + const [showOriginal, setShowOriginal] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isDone, setIsDone] = useState(false); + + const addChatMessage = useCortexStore((s) => s.addChatMessage); + const setNoShowStep = useCortexStore((s) => s.setNoShowStep); + + const handleDispatch = async () => { + setIsSubmitting(true); + + try { + const res = await fetch('/api/cortex/noshow/dispatch', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + documentId: prefill.documentId, + finalContent: content, + }), + }); + + if (!res.ok) throw new Error('Dispatch mislukt'); + + setIsDone(true); + setNoShowStep('done'); + addChatMessage({ + type: 'assistant', + content: 'Brief is verzendklaar gemaakt. De no-show afhandeling is compleet.', + }); + } catch { + addChatMessage({ + type: 'error', + content: 'Opslaan mislukt. Probeer het opnieuw.', + }); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+ {/* Header */} +
+ +

+ {prefill.title || 'Huisartsbrief'} +

+ + Aangepast door Cortex + +
+ + {/* LLM waarschuwing bij fallback naar origineel */} + {prefill.rescriptWarning && ( +
+ ⚠️ {prefill.rescriptWarning} +
+ )} + + {/* Bewerkbare tekstinhoud */} +