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 ? (
+
+ ) : data ? (
+
+ {/* Basisgegevens */}
+
+
+
+
Basisgegevens
+ {patientStatusLabel && (
+
+ {patientStatusLabel}
+
+ )}
+
+
+
+
+
Geboortedatum
+
{patientBirthDate}
+
+
+
+
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