diff --git a/app/api/patients/[patientId]/dashboard/route.ts b/app/api/patients/[patientId]/dashboard/route.ts new file mode 100644 index 0000000..ceea07b --- /dev/null +++ b/app/api/patients/[patientId]/dashboard/route.ts @@ -0,0 +1,69 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { createClient } from '@/lib/auth/server'; +import { getPatient } from '@/app/epd/patients/actions'; +import { getIntakesByPatientId } from '@/app/epd/patients/[id]/intakes/actions'; +import { getPatientEncounters } from '@/app/epd/agenda/actions'; +import { getActiveCarePlan } from '@/app/epd/patients/[id]/behandelplan/actions'; + +interface RouteParams { + params: Promise<{ patientId: string }>; +} + +function extractHulpvraag(notes: string | null): string | null { + if (!notes) return null; + const firstSentence = notes.split(/[.!?]/)[0]; + return firstSentence.length > 150 ? `${firstSentence.slice(0, 150)}...` : firstSentence; +} + +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(); + const { data: authData } = await supabase.auth.getUser(); + if (!authData?.user) { + return NextResponse.json({ error: 'Niet geautoriseerd' }, { status: 401 }); + } + + const patient = await getPatient(patientId); + if (!patient) { + return NextResponse.json({ error: 'Patiënt niet gevonden' }, { status: 404 }); + } + + const [intakes, encounters, carePlan] = await Promise.all([ + getIntakesByPatientId(patientId).catch(() => []), + getPatientEncounters(patientId).catch(() => []), + getActiveCarePlan(patientId).catch(() => null), + ]); + + let hulpvraag: string | null = null; + if (carePlan?.based_on_intake_id) { + const linkedIntake = intakes.find((intake) => intake.id === carePlan.based_on_intake_id); + if (linkedIntake?.notes) { + hulpvraag = extractHulpvraag(linkedIntake.notes); + } + } + + return NextResponse.json({ + patient, + intakes, + encounters, + carePlan, + hulpvraag, + }); + } catch (error) { + console.error('Unexpected error in GET /api/patients/[patientId]/dashboard:', error); + return NextResponse.json( + { error: 'Onverwachte serverfout' }, + { status: 500 } + ); + } +} diff --git a/components/swift/artifacts/artifact-container.tsx b/components/swift/artifacts/artifact-container.tsx index bddd80b..8f93b23 100644 --- a/components/swift/artifacts/artifact-container.tsx +++ b/components/swift/artifacts/artifact-container.tsx @@ -14,6 +14,7 @@ import { ArtifactTab } from './artifact-tab'; import { DagnotatieBlock } from '../blocks/dagnotitie-block'; import { ZoekenBlock } from '../blocks/zoeken-block'; import { OverdrachtBlock } from '../blocks/overdracht-block'; +import { PatientDashboardBlock } from '../blocks/patient-dashboard-block'; import { FallbackPicker } from '../blocks/fallback-picker'; import type { Artifact, BlockType } from '@/stores/swift-store'; @@ -30,13 +31,15 @@ interface ArtifactContainerProps { function renderArtifactBlock(artifact: Artifact) { switch (artifact.type) { case 'dagnotitie': - return ; + return ; case 'zoeken': - return ; + return ; case 'overdracht': - return ; + return ; + case 'patient-dashboard': + return ; case 'fallback': - return ; + return ; default: return (
@@ -61,6 +64,10 @@ export function getArtifactTitle(type: BlockType, prefill?: any): string { return 'Dienst Overdracht'; case 'fallback': return 'Kies een actie'; + case 'patient-dashboard': + return prefill?.patientName + ? `Dashboard - ${prefill.patientName}` + : 'Patiëntoverzicht'; default: return 'Artifact'; } diff --git a/components/swift/blocks/index.ts b/components/swift/blocks/index.ts index 241a9f7..e6bd892 100644 --- a/components/swift/blocks/index.ts +++ b/components/swift/blocks/index.ts @@ -7,4 +7,5 @@ export { DagnotatieBlock } from './dagnotitie-block'; export { ZoekenBlock } from './zoeken-block'; export { OverdrachtBlock } from './overdracht-block'; export { PatientContextCard } from './patient-context-card'; +export { PatientDashboardBlock } from './patient-dashboard-block'; export { FallbackPicker } from './fallback-picker'; diff --git a/components/swift/blocks/patient-dashboard-block.tsx b/components/swift/blocks/patient-dashboard-block.tsx new file mode 100644 index 0000000..37c8af1 --- /dev/null +++ b/components/swift/blocks/patient-dashboard-block.tsx @@ -0,0 +1,369 @@ +'use client'; + +/** + * Patient Dashboard Block + * + * Swift artifact that shows patient properties and dashboard summary. + */ + +import { useEffect, useMemo, useState } from 'react'; +import { format } from 'date-fns'; +import { nl } from 'date-fns/locale'; +import { + AlertCircle, + Calendar, + ClipboardList, + Clock, + FileText, + Loader2, + User, +} from 'lucide-react'; +import { BlockContainer } from './block-container'; +import { safeFetch, getErrorInfo } from '@/lib/swift/error-handler'; +import { useToast } from '@/hooks/use-toast'; +import { BLOCK_CONFIGS } from '@/lib/swift/types'; +import type { BlockPrefillData } from '@/stores/swift-store'; +import type { FHIRPatient } from '@/lib/fhir'; +import type { Intake } from '@/lib/types/intake'; +import { cn } from '@/lib/utils'; + +interface PatientDashboardBlockProps { + prefill?: BlockPrefillData; +} + +interface EncounterSummary { + id: string; + period_start: string; + period_end?: string | null; + type_display?: string | null; + status: string; +} + +interface CarePlanSummary { + id?: string; + title?: string | null; + status?: string | null; + based_on_intake_id?: string | null; + behandelstructuur?: unknown; + goals?: unknown; + activities?: unknown; + evaluatiemomenten?: unknown; +} + +interface PatientDashboardResponse { + patient: FHIRPatient; + intakes: Intake[]; + encounters: EncounterSummary[]; + carePlan: CarePlanSummary | null; + hulpvraag?: string | null; +} + +const STATUS_LABELS: Record = { + planned: 'Screening', + active: 'Actief', + finished: 'Afgerond', + cancelled: 'Afgemeld', +}; + +const GENDER_LABELS: Record = { + male: 'Man', + female: 'Vrouw', + other: 'Anders', + unknown: 'Onbekend', +}; + +function extractEpisodeStatus(patient?: FHIRPatient): string | null { + if (!patient) return null; + const statusExtension = (patient as any)?.extension?.find( + (ext: any) => ext.url === 'http://mini-epd.local/fhir/StructureDefinition/episode-status' + ); + return statusExtension?.valueCode || null; +} + +function getPatientName(patient?: FHIRPatient): string { + const name = patient?.name?.[0]; + if (!name) return 'Onbekende patiënt'; + return [ + ...(name.prefix || []), + ...(name.given || []), + name.family, + ] + .filter(Boolean) + .join(' '); +} + +function getPatientBsn(patient?: FHIRPatient): string | null { + if (!patient?.identifier) return null; + return ( + patient.identifier.find( + (id) => id.system === 'http://fhir.nl/fhir/NamingSystem/bsn' + )?.value || null + ); +} + +export function PatientDashboardBlock({ prefill }: PatientDashboardBlockProps) { + const config = BLOCK_CONFIGS['patient-dashboard']; + const patientId = prefill?.patientId; + const { toast } = useToast(); + + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(Boolean(patientId)); + const [error, setError] = useState(null); + + useEffect(() => { + if (!patientId) { + setError('Geen patiënt geselecteerd'); + setIsLoading(false); + return; + } + + const fetchDashboard = async () => { + setIsLoading(true); + setError(null); + + try { + const response = await safeFetch( + `/api/patients/${patientId}/dashboard`, + undefined, + { operation: 'Patiëntdashboard laden' } + ); + const result = (await response.json()) as PatientDashboardResponse; + setData(result); + } catch (err) { + const statusCode = (err as any)?.statusCode; + const errorInfo = getErrorInfo(err, { + operation: 'Patiëntdashboard laden', + statusCode, + }); + setError(errorInfo.description); + toast({ + variant: 'destructive', + title: errorInfo.title, + description: errorInfo.description, + }); + } finally { + setIsLoading(false); + } + }; + + fetchDashboard(); + }, [patientId, toast]); + + const patient = data?.patient; + const patientName = useMemo(() => getPatientName(patient), [patient]); + const patientStatus = extractEpisodeStatus(patient); + const patientStatusLabel = patientStatus ? STATUS_LABELS[patientStatus] || patientStatus : null; + const patientBirthDate = patient?.birthDate + ? format(new Date(patient.birthDate), 'd MMM yyyy', { locale: nl }) + : 'Onbekend'; + const patientGender = patient?.gender ? GENDER_LABELS[patient.gender] || patient.gender : 'Onbekend'; + const patientBsn = getPatientBsn(patient) || 'Onbekend'; + + const recentIntakes = data?.intakes?.slice(0, 3) || []; + const encounters = data?.encounters || []; + + const encounterGroups = useMemo(() => { + const now = new Date(); + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + + const upcoming = encounters.filter((e) => new Date(e.period_start) >= todayStart); + const recent = encounters.filter((e) => new Date(e.period_start) < todayStart); + + const displayEncounters = [...upcoming, ...recent].slice(0, 5); + return displayEncounters; + }, [encounters]); + + const goalsCount = Array.isArray(data?.carePlan?.goals) ? data?.carePlan?.goals.length : 0; + const interventionsCount = Array.isArray(data?.carePlan?.activities) + ? data?.carePlan?.activities.length + : 0; + + const title = prefill?.patientName + ? `${config.title} - ${prefill.patientName}` + : config.title; + + return ( + + {isLoading ? ( +
+ + Dashboard laden... +
+ ) : error ? ( +
+ +

{error}

+
+ ) : data ? ( +
+ {/* Basisgegevens */} +
+
+ +

Basisgegevens

+ {patientStatusLabel && ( + + {patientStatusLabel} + + )} +
+
+
+

Naam

+

{patientName}

+
+
+

Geboortedatum

+

{patientBirthDate}

+
+
+

BSN

+

{patientBsn}

+
+
+

Geslacht

+

{patientGender}

+
+
+
+ + {/* Recente intakes */} +
+
+ +

Recente intakes

+ ({data.intakes.length}) +
+ {recentIntakes.length === 0 ? ( +

Geen intakes gevonden

+ ) : ( +
+ {recentIntakes.map((intake) => ( +
+
+

{intake.title}

+
+ {intake.department} + + + {format(new Date(intake.start_date), 'd MMM yyyy', { locale: nl })} + +
+
+ + {intake.status} + +
+ ))} +
+ )} +
+ + {/* Agenda afspraken */} +
+
+ +

Agenda afspraken

+ ({encounters.length}) +
+ {encounterGroups.length === 0 ? ( +

Geen afspraken gevonden

+ ) : ( +
+ {encounterGroups.map((encounter) => { + const encounterDate = new Date(encounter.period_start); + const isPast = encounterDate < new Date(); + + return ( +
+
+

+ {encounter.type_display || 'Afspraak'} +

+
+ + + {format(encounterDate, 'd MMM yyyy HH:mm', { locale: nl })} + + {encounter.period_end && ( + <> + + + {format(new Date(encounter.period_end), 'HH:mm', { locale: nl })} + + + )} +
+
+ + {encounter.status === 'planned' + ? 'Gepland' + : encounter.status === 'arrived' + ? 'Aangekomen' + : encounter.status === 'finished' + ? 'Afgerond' + : encounter.status} + +
+ ); + })} +
+ )} +
+ + {/* Behandelplan */} +
+
+ +

Actief behandelplan

+
+ {data.carePlan ? ( +
+ {data.hulpvraag && ( +
+

Hulpvraag

+

“{data.hulpvraag}”

+
+ )} +
+
+

Doelen

+

{goalsCount}

+
+
+

Interventies

+

{interventionsCount}

+
+
+
+ ) : ( +

Geen actief behandelplan

+ )} +
+
+ ) : ( +
Geen gegevens beschikbaar
+ )} +
+ ); +} diff --git a/components/swift/blocks/zoeken-block.tsx b/components/swift/blocks/zoeken-block.tsx index aa55e85..1ba0d08 100644 --- a/components/swift/blocks/zoeken-block.tsx +++ b/components/swift/blocks/zoeken-block.tsx @@ -34,11 +34,12 @@ interface PatientSearchResult { export function ZoekenBlock({ prefill }: ZoekenBlockProps) { const config = BLOCK_CONFIGS.zoeken; - const { closeBlock, setActivePatient, addRecentAction } = useSwiftStore(); + const { closeBlock, setActivePatient, addRecentAction, openArtifact } = useSwiftStore(); const { toast } = useToast(); + const prefillQuery = prefill?.patientName || prefill?.query || ''; // Search state - const [searchQuery, setSearchQuery] = useState(prefill?.patientName || ''); + const [searchQuery, setSearchQuery] = useState(prefillQuery); const [patients, setPatients] = useState([]); const [isSearching, setIsSearching] = useState(false); const [selectedPatientId, setSelectedPatientId] = useState(null); @@ -81,14 +82,14 @@ export function ZoekenBlock({ prefill }: ZoekenBlockProps) { // Prefill search query useEffect(() => { - if (prefill?.patientName) { - setSearchQuery(prefill.patientName); + if (prefillQuery) { + setSearchQuery(prefillQuery); // Auto-search if prefill is provided - if (prefill.patientName.length >= 2) { - searchPatients(prefill.patientName); + if (prefillQuery.length >= 2) { + searchPatients(prefillQuery); } } - }, [prefill, searchPatients]); + }, [prefillQuery, searchPatients]); // Debounced search useEffect(() => { @@ -201,14 +202,16 @@ export function ZoekenBlock({ prefill }: ZoekenBlockProps) { description: `${patient.name} is nu actief`, }); - // Close search block and open PatientContextCard + // Close legacy block (v2) and open dashboard artifact (v3) closeBlock(); - - // Open PatientContextCard after a short delay to allow ZoekenBlock to close - setTimeout(() => { - // PatientContextCard will auto-open when activePatient is set - // We don't need to explicitly open it as a block - it's shown automatically - }, 100); + openArtifact({ + type: 'patient-dashboard', + title: `Dashboard - ${patient.name}`, + prefill: { + patientId: patient.id, + patientName: patient.name, + }, + }); } catch (error) { console.error('Failed to select patient:', error); const statusCode = (error as any)?.statusCode; diff --git a/docs/swift/bouwplan-swift-standalone-module.md b/docs/swift/bouwplan-swift-standalone-module.md index 00d80f9..c05d4bb 100644 --- a/docs/swift/bouwplan-swift-standalone-module.md +++ b/docs/swift/bouwplan-swift-standalone-module.md @@ -64,7 +64,7 @@ Development practices: | E0 | Product/UX alignment | Scope en UX flows vastleggen | Done | 2 | Route group akkoord | | E1 | Routing & layout scheiding | Swift los van EPD layout | Done | 4 | `/epd/swift` blijft | | E2 | Navigatie & toegang | Entry/exit flows borgen | Done | 3 | MVP only | -| E3 | Swift shell polish | Full-screen gedrag + responsive | To Do | 3 | Geen redesign | +| E3 | Swift shell polish | Full-screen gedrag + responsive | Done | 3 | Geen redesign | | E4 | QA & docs | Validatie en documentatie | To Do | 3 | Manual checks | --- @@ -114,9 +114,9 @@ Epic doel: full-screen ervaring is clean en consistent. | Story ID | Beschrijving | Acceptatiecriteria | Status | Afhankelijkheden | Story Points | |----------|--------------|---------------------|--------|------------------|--------------| -| E3.S1 | Full-screen layout check | Geen sidebar spacing of EPD padding zichtbaar | To Do | E1.S2 | 2 | -| E3.S2 | Responsive gedrag check | Chat/artifacts stacken op mobiel zoals nu | To Do | E1.S2 | 2 | -| E3.S3 | Visuele consistentie | Context bar en offline banner correct gepositioneerd | To Do | E1.S2 | 1 | +| E3.S1 | Full-screen layout check | Geen sidebar spacing of EPD padding zichtbaar | Done | E1.S2 | 2 | +| E3.S2 | Responsive gedrag check | Chat/artifacts stacken op mobiel zoals nu | Done | E1.S2 | 2 | +| E3.S3 | Visuele consistentie | Context bar en offline banner correct gepositioneerd | Done | E1.S2 | 1 | Technical notes: - Geen redesign of nieuwe UI componenten. diff --git a/lib/swift/action-parser.ts b/lib/swift/action-parser.ts index d860520..97ed29f 100644 --- a/lib/swift/action-parser.ts +++ b/lib/swift/action-parser.ts @@ -24,7 +24,7 @@ const ActionSchema = z.object({ confidence: z.number().min(0).max(1), artifact: z .object({ - type: z.enum(['dagnotitie', 'zoeken', 'overdracht', 'fallback']), + type: z.enum(['dagnotitie', 'zoeken', 'overdracht', 'fallback', 'patient-dashboard']), prefill: z.record(z.string(), z.any()), }) .optional(), @@ -140,6 +140,7 @@ export function getConfidenceLabel(confidence: number): string { */ export function validateArtifactType(intent: SwiftIntent, artifactType?: BlockType): boolean { if (!artifactType) return true; // No artifact is valid + if (artifactType === 'patient-dashboard') return true; // Intent should match artifact type (except for 'unknown' and 'fallback') if (intent === 'unknown') return artifactType === 'fallback'; diff --git a/lib/swift/types.ts b/lib/swift/types.ts index d0c7d95..83c63b1 100644 --- a/lib/swift/types.ts +++ b/lib/swift/types.ts @@ -9,7 +9,7 @@ import type { VerpleegkundigCategory } from '@/lib/types/report'; // Intent types export type SwiftIntent = 'dagnotitie' | 'zoeken' | 'overdracht' | 'unknown'; -export type BlockType = Exclude; +export type BlockType = Exclude | 'patient-dashboard'; // Shift types export type ShiftType = 'nacht' | 'ochtend' | 'middag' | 'avond'; @@ -28,6 +28,7 @@ export interface ExtractedEntities { patientId?: string; category?: VerpleegkundigCategory; content?: string; + query?: string; } // Block sizes @@ -61,6 +62,12 @@ export const BLOCK_CONFIGS: Record = { size: 'lg', icon: 'ArrowRightLeft', }, + 'patient-dashboard': { + type: 'patient-dashboard', + title: 'Patiëntoverzicht', + size: 'lg', + icon: 'LayoutDashboard', + }, }; // Recent action type diff --git a/stores/swift-store.ts b/stores/swift-store.ts index 9c128b7..9c82e25 100644 --- a/stores/swift-store.ts +++ b/stores/swift-store.ts @@ -15,7 +15,7 @@ export type SwiftIntent = | 'overdracht' | 'unknown'; -export type BlockType = Exclude | 'fallback'; +export type BlockType = Exclude | 'fallback' | 'patient-dashboard'; // Chat types (v3.0) export type ChatMessageType = 'user' | 'assistant' | 'system' | 'error'; @@ -44,6 +44,7 @@ export interface ExtractedEntities { patientId?: string; category?: VerpleegkundigCategory; content?: string; + query?: string; } // Block prefill data