FHIR, API, datamodel

This commit is contained in:
colinislit
2025-11-21 22:32:29 +01:00
parent 5f7ef0801d
commit d5c6cf208b
39 changed files with 17974 additions and 1004 deletions

View File

@@ -8,28 +8,69 @@ import Image from 'next/image'
import Link from 'next/link'
import type { MDXComponents } from 'mdx/types'
/**
* Generate slug from heading text for anchor links
*/
function slugify(text: string): string {
return text
.toString()
.toLowerCase()
.trim()
.replace(/\s+/g, '-')
.replace(/[^\w\-]+/g, '')
.replace(/\-\-+/g, '-')
}
/**
* Extract text content from React children
*/
function getTextContent(children: React.ReactNode): string {
if (typeof children === 'string') return children
if (Array.isArray(children)) return children.map(getTextContent).join('')
if (children && typeof children === 'object' && 'props' in children) {
return getTextContent(children.props.children)
}
return ''
}
export const mdxComponents: MDXComponents = {
// Headings with anchor links
h1: ({ children, ...props }) => (
<h1 className="text-4xl font-bold text-slate-900 mt-8 mb-4" {...props}>
{children}
</h1>
),
h2: ({ children, ...props }) => (
<h2 className="text-3xl font-bold text-slate-900 mt-8 mb-4 border-b border-slate-200 pb-2" {...props}>
{children}
</h2>
),
h3: ({ children, ...props }) => (
<h3 className="text-2xl font-semibold text-slate-900 mt-6 mb-3" {...props}>
{children}
</h3>
),
h4: ({ children, ...props }) => (
<h4 className="text-xl font-semibold text-slate-900 mt-4 mb-2" {...props}>
{children}
</h4>
),
h1: ({ children, ...props }) => {
const text = getTextContent(children)
const id = slugify(text)
return (
<h1 id={id} className="text-4xl font-bold text-slate-900 mt-8 mb-4 scroll-mt-20" {...props}>
{children}
</h1>
)
},
h2: ({ children, ...props }) => {
const text = getTextContent(children)
const id = slugify(text)
return (
<h2 id={id} className="text-3xl font-bold text-slate-900 mt-8 mb-4 border-b border-slate-200 pb-2 scroll-mt-20" {...props}>
{children}
</h2>
)
},
h3: ({ children, ...props }) => {
const text = getTextContent(children)
const id = slugify(text)
return (
<h3 id={id} className="text-2xl font-semibold text-slate-900 mt-6 mb-3 scroll-mt-20" {...props}>
{children}
</h3>
)
},
h4: ({ children, ...props }) => {
const text = getTextContent(children)
const id = slugify(text)
return (
<h4 id={id} className="text-xl font-semibold text-slate-900 mt-4 mb-2 scroll-mt-20" {...props}>
{children}
</h4>
)
},
// Paragraphs
p: ({ children, ...props }) => (
@@ -67,7 +108,7 @@ export const mdxComponents: MDXComponents = {
),
// Images
img: ({ src, alt, ...props }) => {
img: ({ src, alt, width, height, ...props }) => {
if (!src) return null
return (
@@ -76,8 +117,8 @@ export const mdxComponents: MDXComponents = {
<Image
src={src}
alt={alt || ''}
width={1200}
height={675}
width={Number(width) || 1200}
height={Number(height) || 675}
className="w-full h-auto"
{...props}
/>

View File

@@ -7,7 +7,7 @@
*/
import { ReleaseSidebar } from './release-sidebar'
import type { ReleaseNote, GroupMetadata, CategoryMetadata } from '@/lib/mdx/documentatie'
import type { ReleaseNote, GroupMetadata, CategoryMetadata, TocItem } from '@/lib/mdx/documentatie'
interface ReleaseSidebarWrapperProps {
releases: ReleaseNote[]
@@ -15,6 +15,7 @@ interface ReleaseSidebarWrapperProps {
groups: GroupMetadata[]
categories: CategoryMetadata[]
}
tocMap: Record<string, TocItem[]>
}
export default function ReleaseSidebarWrapper(props: ReleaseSidebarWrapperProps) {

View File

@@ -11,7 +11,7 @@ import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { ChevronRight, ChevronDown, ChevronLeft, X } from 'lucide-react'
import { useState, useEffect } from 'react'
import type { ReleaseNote, GroupMetadata, CategoryMetadata } from '@/lib/mdx/documentatie'
import type { ReleaseNote, GroupMetadata, CategoryMetadata, TocItem } from '@/lib/mdx/documentatie'
interface ReleaseSidebarProps {
releases: ReleaseNote[]
@@ -19,13 +19,15 @@ interface ReleaseSidebarProps {
groups: GroupMetadata[]
categories: CategoryMetadata[]
}
tocMap: Record<string, TocItem[]>
}
export function ReleaseSidebar({ releases, metadata }: ReleaseSidebarProps) {
export function ReleaseSidebar({ releases, metadata, tocMap }: ReleaseSidebarProps) {
const pathname = usePathname()
const [isExpanded, setIsExpanded] = useState(false)
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({
foundation: true,
architecture: true,
features: true,
infrastructure: true,
bugs: true,
@@ -143,25 +145,44 @@ export function ReleaseSidebar({ releases, metadata }: ReleaseSidebarProps) {
<div className="space-y-1 ml-2">
{groupReleases.map((release) => {
const isActive = pathname === `/documentatie/${release.slug}`
const toc = tocMap[release.slug] || []
return (
<Link
key={release.slug}
href={`/documentatie/${release.slug}`}
className={`flex items-center justify-between px-3 py-2 rounded-lg text-sm font-medium transition-colors group ${
isActive
? 'bg-teal-50 text-teal-700'
: 'text-slate-700 hover:bg-slate-50'
}`}
>
<div className="flex items-center gap-2 min-w-0">
<StatusDot status={release.frontmatter.status} />
<span className="whitespace-normal">{release.frontmatter.title}</span>
</div>
<ChevronRight className={`w-4 h-4 flex-shrink-0 transition-opacity ${
isActive ? 'opacity-100' : 'opacity-0 group-hover:opacity-50'
}`} />
</Link>
<div key={release.slug}>
<Link
href={`/documentatie/${release.slug}`}
className={`flex items-center justify-between px-3 py-2 rounded-lg text-sm font-medium transition-colors group ${
isActive
? 'bg-teal-50 text-teal-700'
: 'text-slate-700 hover:bg-slate-50'
}`}
>
<div className="flex items-center gap-2 min-w-0">
<StatusDot status={release.frontmatter.status} />
<span className="whitespace-normal">{release.frontmatter.title}</span>
</div>
<ChevronRight className={`w-4 h-4 flex-shrink-0 transition-opacity ${
isActive ? 'opacity-100' : 'opacity-0 group-hover:opacity-50'
}`} />
</Link>
{/* Table of Contents for active page */}
{isActive && toc.length > 0 && (
<div className="ml-4 mt-1 space-y-1 border-l-2 border-teal-200">
{toc.map((item) => (
<a
key={item.id}
href={`#${item.id}`}
className={`block py-1.5 text-xs text-slate-600 hover:text-teal-600 transition-colors ${
item.level === 2 ? 'pl-3 font-medium' : 'pl-6'
}`}
>
{item.text}
</a>
))}
</div>
)}
</div>
)
})}
</div>
@@ -220,23 +241,42 @@ export function ReleaseSidebar({ releases, metadata }: ReleaseSidebarProps) {
<div className="space-y-1 ml-2">
{groupReleases.map((release) => {
const isActive = pathname === `/documentatie/${release.slug}`
const toc = tocMap[release.slug] || []
return (
<Link
key={release.slug}
href={`/documentatie/${release.slug}`}
className={`flex items-center justify-between px-3 py-2 rounded-lg text-sm font-medium transition-colors group ${isActive
? 'bg-teal-50 text-teal-700'
: 'text-slate-700 hover:bg-slate-50'
}`}
>
<div className="flex items-center gap-2 min-w-0">
<StatusDot status={release.frontmatter.status} />
<span className="whitespace-normal">{release.frontmatter.title}</span>
</div>
<ChevronRight className={`w-4 h-4 flex-shrink-0 transition-opacity ${isActive ? 'opacity-100' : 'opacity-0 group-hover:opacity-50'
}`} />
</Link>
<div key={release.slug}>
<Link
href={`/documentatie/${release.slug}`}
className={`flex items-center justify-between px-3 py-2 rounded-lg text-sm font-medium transition-colors group ${isActive
? 'bg-teal-50 text-teal-700'
: 'text-slate-700 hover:bg-slate-50'
}`}
>
<div className="flex items-center gap-2 min-w-0">
<StatusDot status={release.frontmatter.status} />
<span className="whitespace-normal">{release.frontmatter.title}</span>
</div>
<ChevronRight className={`w-4 h-4 flex-shrink-0 transition-opacity ${isActive ? 'opacity-100' : 'opacity-0 group-hover:opacity-50'
}`} />
</Link>
{/* Table of Contents for active page */}
{isActive && toc.length > 0 && (
<div className="ml-4 mt-1 space-y-1 border-l-2 border-teal-200">
{toc.map((item) => (
<a
key={item.id}
href={`#${item.id}`}
className={`block py-1.5 text-xs text-slate-600 hover:text-teal-600 transition-colors ${
item.level === 2 ? 'pl-3 font-medium' : 'pl-6'
}`}
>
{item.text}
</a>
))}
</div>
)}
</div>
)
})}
</div>

View File

@@ -5,7 +5,7 @@
*/
import type { ReactNode } from 'react'
import { getAllReleases, getCategoryMetadata, type ReleaseNote } from '@/lib/mdx/documentatie'
import { getAllReleases, getCategoryMetadata, extractHeadings, type ReleaseNote, type TocItem } from '@/lib/mdx/documentatie'
import ReleaseSidebarWrapper from './components/release-sidebar-wrapper'
interface ReleasesLayoutProps {
@@ -15,10 +15,17 @@ interface ReleasesLayoutProps {
export default async function ReleasesLayout({ children }: ReleasesLayoutProps) {
let releases: ReleaseNote[] = []
let metadata = { groups: [], categories: [] } as Awaited<ReturnType<typeof getCategoryMetadata>>
let tocMap: Record<string, TocItem[]> = {}
try {
releases = await getAllReleases()
metadata = await getCategoryMetadata()
// Extract headings for each release
tocMap = releases.reduce((acc, release) => {
acc[release.slug] = extractHeadings(release.content)
return acc
}, {} as Record<string, TocItem[]>)
} catch (error) {
console.error('Error loading releases or metadata:', error)
}
@@ -30,7 +37,7 @@ export default async function ReleasesLayout({ children }: ReleasesLayoutProps)
<div className="max-w-[1600px] mx-auto">
<div className="flex">
{/* Sidebar - collapsible on mobile (48px collapsed), fixed on desktop (320px) */}
<ReleaseSidebarWrapper releases={releases} metadata={metadata} />
<ReleaseSidebarWrapper releases={releases} metadata={metadata} tocMap={tocMap} />
{/* Main Content - margin for collapsed sidebar on mobile, fixed sidebar on desktop */}
<div className="flex-1 ml-12 lg:ml-80">

View File

@@ -0,0 +1,163 @@
/**
* FHIR Patient API - Instance Endpoints
* GET /api/fhir/Patient/[id] - Read patient
* PUT /api/fhir/Patient/[id] - Update patient
*/
import { NextRequest, NextResponse } from 'next/server';
import { supabaseAdmin } from '@/lib/supabase/server';
import {
dbPatientToFHIR,
fhirPatientToDB,
createOperationOutcome,
validateFHIRResource,
} from '@/lib/fhir';
import type { FHIRPatient } from '@/lib/fhir';
/**
* GET /api/fhir/Patient/[id]
* Returns a single FHIR Patient resource
*/
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const { id } = params;
// Query patient by ID
const { data: patient, error } = await supabaseAdmin
.from('patients')
.select('*')
.eq('id', id)
.single();
if (error || !patient) {
return NextResponse.json(
createOperationOutcome(
'error',
'not-found',
`Patient with id ${id} not found`
),
{ status: 404 }
);
}
// Transform to FHIR resource
const fhirPatient = dbPatientToFHIR(patient);
return NextResponse.json(fhirPatient);
} catch (error) {
return NextResponse.json(
createOperationOutcome(
'error',
'exception',
error instanceof Error ? error.message : 'Unknown error'
),
{ status: 500 }
);
}
}
/**
* PUT /api/fhir/Patient/[id]
* Updates a patient from FHIR Patient resource
*/
export async function PUT(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const { id } = params;
const fhirPatient: FHIRPatient = await request.json();
// Validate required fields
const validation = validateFHIRResource(fhirPatient, [
'resourceType',
'name',
'gender',
'birthDate',
]);
if (!validation.valid) {
return NextResponse.json(
createOperationOutcome('error', 'invalid', validation.errors.join(', ')),
{ status: 400 }
);
}
// Verify resourceType
if (fhirPatient.resourceType !== 'Patient') {
return NextResponse.json(
createOperationOutcome(
'error',
'invalid',
`Expected resourceType "Patient", got "${fhirPatient.resourceType}"`
),
{ status: 400 }
);
}
// Verify ID matches
if (fhirPatient.id && fhirPatient.id !== id) {
return NextResponse.json(
createOperationOutcome(
'error',
'invalid',
`ID in URL (${id}) does not match ID in resource (${fhirPatient.id})`
),
{ status: 400 }
);
}
// Check if patient exists
const { data: existingPatient, error: fetchError } = await supabaseAdmin
.from('patients')
.select('id')
.eq('id', id)
.single();
if (fetchError || !existingPatient) {
return NextResponse.json(
createOperationOutcome(
'error',
'not-found',
`Patient with id ${id} not found`
),
{ status: 404 }
);
}
// Transform to database format
const patientUpdate = fhirPatientToDB(fhirPatient);
// Update in database
const { data: updatedPatient, error: updateError } = await supabaseAdmin
.from('patients')
.update(patientUpdate)
.eq('id', id)
.select()
.single();
if (updateError) {
return NextResponse.json(
createOperationOutcome('error', 'processing', updateError.message),
{ status: 500 }
);
}
// Return updated patient as FHIR resource
const fhirResponse = dbPatientToFHIR(updatedPatient);
return NextResponse.json(fhirResponse);
} catch (error) {
return NextResponse.json(
createOperationOutcome(
'error',
'exception',
error instanceof Error ? error.message : 'Unknown error'
),
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,148 @@
/**
* FHIR Patient API - Collection Endpoints
* GET /api/fhir/Patient - List patients
* POST /api/fhir/Patient - Create patient
*/
import { NextRequest, NextResponse } from 'next/server';
import { supabaseAdmin } from '@/lib/supabase/server';
import {
dbPatientToFHIR,
fhirPatientToDB,
createOperationOutcome,
validateFHIRResource,
} from '@/lib/fhir';
import type { FHIRBundle, FHIRPatient } from '@/lib/fhir';
/**
* GET /api/fhir/Patient
* Returns a FHIR Bundle with searchset of patients
* Supports query parameters: name, identifier, birthdate
*/
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
// Build query
let query = supabaseAdmin.from('patients').select('*');
// Search by name (family or given)
const name = searchParams.get('name');
if (name) {
query = query.or(
`name_family.ilike.%${name}%,name_given.cs.{${name}}`
);
}
// Search by identifier (BSN)
const identifier = searchParams.get('identifier');
if (identifier) {
query = query.eq('identifier_bsn', identifier);
}
// Search by birth date
const birthdate = searchParams.get('birthdate');
if (birthdate) {
query = query.eq('birth_date', birthdate);
}
// Execute query
const { data: patients, error } = await query;
if (error) {
return NextResponse.json(
createOperationOutcome('error', 'processing', error.message),
{ status: 500 }
);
}
// Transform to FHIR Bundle
const bundle: FHIRBundle<FHIRPatient> = {
resourceType: 'Bundle',
type: 'searchset',
total: patients?.length || 0,
entry: patients?.map((patient) => ({
resource: dbPatientToFHIR(patient),
})) || [],
};
return NextResponse.json(bundle);
} catch (error) {
return NextResponse.json(
createOperationOutcome(
'error',
'exception',
error instanceof Error ? error.message : 'Unknown error'
),
{ status: 500 }
);
}
}
/**
* POST /api/fhir/Patient
* Creates a new patient from FHIR Patient resource
*/
export async function POST(request: NextRequest) {
try {
const fhirPatient: FHIRPatient = await request.json();
// Validate required fields
const validation = validateFHIRResource(fhirPatient, [
'resourceType',
'name',
'gender',
'birthDate',
]);
if (!validation.valid) {
return NextResponse.json(
createOperationOutcome('error', 'invalid', validation.errors.join(', ')),
{ status: 400 }
);
}
// Verify resourceType
if (fhirPatient.resourceType !== 'Patient') {
return NextResponse.json(
createOperationOutcome(
'error',
'invalid',
`Expected resourceType "Patient", got "${fhirPatient.resourceType}"`
),
{ status: 400 }
);
}
// Transform to database format
const patientInsert = fhirPatientToDB(fhirPatient);
// Insert into database
const { data: newPatient, error } = await supabaseAdmin
.from('patients')
.insert(patientInsert)
.select()
.single();
if (error) {
return NextResponse.json(
createOperationOutcome('error', 'processing', error.message),
{ status: 500 }
);
}
// Return created patient as FHIR resource
const fhirResponse = dbPatientToFHIR(newPatient);
return NextResponse.json(fhirResponse, { status: 201 });
} catch (error) {
return NextResponse.json(
createOperationOutcome(
'error',
'exception',
error instanceof Error ? error.message : 'Unknown error'
),
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,56 @@
/**
* FHIR Practitioner API - Instance Endpoints
* GET /api/fhir/Practitioner/[id] - Read practitioner
*/
import { NextRequest, NextResponse } from 'next/server';
import { supabaseAdmin } from '@/lib/supabase/server';
import {
dbPractitionerToFHIR,
createOperationOutcome,
} from '@/lib/fhir';
/**
* GET /api/fhir/Practitioner/[id]
* Returns a single FHIR Practitioner resource
*/
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const { id } = params;
// Query practitioner by ID
const { data: practitioner, error } = await supabaseAdmin
.from('practitioners')
.select('*')
.eq('id', id)
.single();
if (error || !practitioner) {
return NextResponse.json(
createOperationOutcome(
'error',
'not-found',
`Practitioner with id ${id} not found`
),
{ status: 404 }
);
}
// Transform to FHIR resource
const fhirPractitioner = dbPractitionerToFHIR(practitioner);
return NextResponse.json(fhirPractitioner);
} catch (error) {
return NextResponse.json(
createOperationOutcome(
'error',
'exception',
error instanceof Error ? error.message : 'Unknown error'
),
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,142 @@
/**
* FHIR Practitioner API - Collection Endpoints
* GET /api/fhir/Practitioner - List practitioners
* POST /api/fhir/Practitioner - Create practitioner
*/
import { NextRequest, NextResponse } from 'next/server';
import { supabaseAdmin } from '@/lib/supabase/server';
import {
dbPractitionerToFHIR,
fhirPractitionerToDB,
createOperationOutcome,
validateFHIRResource,
} from '@/lib/fhir';
import type { FHIRBundle, FHIRPractitioner } from '@/lib/fhir';
/**
* GET /api/fhir/Practitioner
* Returns a FHIR Bundle with searchset of practitioners
* Supports query parameters: name, identifier
*/
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
// Build query
let query = supabaseAdmin.from('practitioners').select('*');
// Search by name (family or given)
const name = searchParams.get('name');
if (name) {
query = query.or(
`name_family.ilike.%${name}%,name_given.cs.{${name}}`
);
}
// Search by identifier (BIG or AGB)
const identifier = searchParams.get('identifier');
if (identifier) {
query = query.or(
`identifier_big.eq.${identifier},identifier_agb.eq.${identifier}`
);
}
// Execute query
const { data: practitioners, error } = await query;
if (error) {
return NextResponse.json(
createOperationOutcome('error', 'processing', error.message),
{ status: 500 }
);
}
// Transform to FHIR Bundle
const bundle: FHIRBundle<FHIRPractitioner> = {
resourceType: 'Bundle',
type: 'searchset',
total: practitioners?.length || 0,
entry: practitioners?.map((practitioner) => ({
resource: dbPractitionerToFHIR(practitioner),
})) || [],
};
return NextResponse.json(bundle);
} catch (error) {
return NextResponse.json(
createOperationOutcome(
'error',
'exception',
error instanceof Error ? error.message : 'Unknown error'
),
{ status: 500 }
);
}
}
/**
* POST /api/fhir/Practitioner
* Creates a new practitioner from FHIR Practitioner resource
*/
export async function POST(request: NextRequest) {
try {
const fhirPractitioner: FHIRPractitioner = await request.json();
// Validate required fields
const validation = validateFHIRResource(fhirPractitioner, [
'resourceType',
'name',
]);
if (!validation.valid) {
return NextResponse.json(
createOperationOutcome('error', 'invalid', validation.errors.join(', ')),
{ status: 400 }
);
}
// Verify resourceType
if (fhirPractitioner.resourceType !== 'Practitioner') {
return NextResponse.json(
createOperationOutcome(
'error',
'invalid',
`Expected resourceType "Practitioner", got "${fhirPractitioner.resourceType}"`
),
{ status: 400 }
);
}
// Transform to database format
const practitionerInsert = fhirPractitionerToDB(fhirPractitioner);
// Insert into database
const { data: newPractitioner, error } = await supabaseAdmin
.from('practitioners')
.insert(practitionerInsert)
.select()
.single();
if (error) {
return NextResponse.json(
createOperationOutcome('error', 'processing', error.message),
{ status: 500 }
);
}
// Return created practitioner as FHIR resource
const fhirResponse = dbPractitionerToFHIR(newPractitioner);
return NextResponse.json(fhirResponse, { status: 201 });
} catch (error) {
return NextResponse.json(
createOperationOutcome(
'error',
'exception',
error instanceof Error ? error.message : 'Unknown error'
),
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,46 @@
import { getPatient } from '../actions';
import { PatientForm } from '../components/patient-form';
import Link from 'next/link';
import { ChevronLeft } from 'lucide-react';
export default async function PatientDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const patient = await getPatient(id);
const name = patient.name?.[0];
const fullName = [
...(name?.prefix || []),
...(name?.given || []),
name?.family,
]
.filter(Boolean)
.join(' ');
return (
<div className="px-4 sm:px-6 lg:px-8 py-8">
{/* Header with back button */}
<div className="mb-8">
<Link
href="/epd/patients"
className="inline-flex items-center gap-2 text-sm text-slate-600 hover:text-slate-900 mb-4 transition-colors"
>
<ChevronLeft className="h-4 w-4" />
<span>Terug naar patiënten</span>
</Link>
<h1 className="text-2xl font-bold text-slate-900">Patiënt bewerken</h1>
<p className="text-sm text-slate-600 mt-1">
{fullName} - ID: {patient.id}
</p>
</div>
{/* Patient Form */}
<div className="bg-white rounded-lg border border-slate-200 p-6">
<PatientForm patient={patient} />
</div>
</div>
);
}

117
app/epd/patients/actions.ts Normal file
View File

@@ -0,0 +1,117 @@
'use server';
/**
* Patient CRUD Server Actions (FHIR-based)
*
* Server-side actions that interact with FHIR Patient API
*/
import { revalidatePath } from 'next/cache';
import type { FHIRPatient, FHIRBundle } from '@/lib/fhir';
const API_BASE_URL = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';
/**
* Get all patients via FHIR API
*/
export async function getPatients(filters?: {
search?: string;
}) {
try {
const url = new URL(`${API_BASE_URL}/api/fhir/Patient`);
if (filters?.search) {
url.searchParams.set('name', filters.search);
}
const response = await fetch(url.toString(), {
cache: 'no-store',
});
if (!response.ok) {
throw new Error(`Failed to fetch patients: ${response.statusText}`);
}
const bundle: FHIRBundle<FHIRPatient> = await response.json();
return bundle.entry?.map((entry) => entry.resource).filter(Boolean) as FHIRPatient[] || [];
} catch (error) {
console.error('Error fetching patients:', error);
throw new Error('Failed to fetch patients');
}
}
/**
* Get single patient by ID via FHIR API
*/
export async function getPatient(id: string) {
try {
const response = await fetch(`${API_BASE_URL}/api/fhir/Patient/${id}`, {
cache: 'no-store',
});
if (!response.ok) {
throw new Error(`Failed to fetch patient: ${response.statusText}`);
}
const patient: FHIRPatient = await response.json();
return patient;
} catch (error) {
console.error('Error fetching patient:', error);
throw new Error('Failed to fetch patient');
}
}
/**
* Create new patient via FHIR API
*/
export async function createPatient(fhirPatient: FHIRPatient) {
try {
const response = await fetch(`${API_BASE_URL}/api/fhir/Patient`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(fhirPatient),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.issue?.[0]?.diagnostics || 'Failed to create patient');
}
const patient: FHIRPatient = await response.json();
revalidatePath('/epd/patients');
return patient;
} catch (error) {
console.error('Error creating patient:', error);
throw error instanceof Error ? error : new Error('Failed to create patient');
}
}
/**
* Update existing patient via FHIR API
*/
export async function updatePatient(id: string, fhirPatient: FHIRPatient) {
try {
const response = await fetch(`${API_BASE_URL}/api/fhir/Patient/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ ...fhirPatient, id }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.issue?.[0]?.diagnostics || 'Failed to update patient');
}
const patient: FHIRPatient = await response.json();
revalidatePath('/epd/patients');
revalidatePath(`/epd/patients/${id}`);
return patient;
} catch (error) {
console.error('Error updating patient:', error);
throw error instanceof Error ? error : new Error('Failed to update patient');
}
}

View File

@@ -0,0 +1,254 @@
'use client';
/**
* Patient Form Component (FHIR-based)
* Form for creating/editing patients using FHIR format
*/
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Save, Loader2 } from 'lucide-react';
import { createPatient, updatePatient } from '../actions';
import type { FHIRPatient } from '@/lib/fhir';
interface PatientFormProps {
patient?: FHIRPatient;
}
export function PatientForm({ patient }: PatientFormProps) {
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const existingName = patient?.name?.[0];
const existingBsn = patient?.identifier?.find(
(id) => id.system === 'http://fhir.nl/fhir/NamingSystem/bsn'
)?.value;
const existingPhone = patient?.telecom?.find((t) => t.system === 'phone')?.value;
const existingEmail = patient?.telecom?.find((t) => t.system === 'email')?.value;
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setIsSubmitting(true);
setError(null);
try {
const formData = new FormData(e.currentTarget);
// Build FHIR Patient resource
const fhirPatient: FHIRPatient = {
resourceType: 'Patient',
identifier: [
{
system: 'http://fhir.nl/fhir/NamingSystem/bsn',
value: formData.get('bsn') as string,
use: 'official' as const,
},
],
name: [
{
use: 'official' as const,
family: formData.get('family') as string,
given: [formData.get('given') as string].filter(Boolean),
prefix: formData.get('prefix')
? [(formData.get('prefix') as string)]
: undefined,
},
],
gender: formData.get('gender') as 'male' | 'female' | 'other' | 'unknown',
birthDate: formData.get('birthDate') as string,
telecom: [
formData.get('phone')
? {
system: 'phone' as const,
value: formData.get('phone') as string,
use: 'mobile' as const,
}
: undefined,
formData.get('email')
? {
system: 'email' as const,
value: formData.get('email') as string,
}
: undefined,
].filter((t): t is NonNullable<typeof t> => t !== undefined),
active: true,
};
if (patient?.id) {
// Update existing patient
await updatePatient(patient.id, fhirPatient);
} else {
// Create new patient
await createPatient(fhirPatient);
}
router.push('/epd/patients');
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : 'Er is een fout opgetreden');
setIsSubmitting(false);
}
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<p className="text-sm text-red-800">{error}</p>
</div>
)}
{/* Name Fields */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label htmlFor="prefix" className="block text-sm font-medium text-slate-700 mb-1">
Voorvoegsel
</label>
<input
type="text"
id="prefix"
name="prefix"
defaultValue={existingName?.prefix?.[0] || ''}
placeholder="Dhr./Mevr."
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-transparent"
/>
</div>
<div>
<label htmlFor="given" className="block text-sm font-medium text-slate-700 mb-1">
Voornaam *
</label>
<input
type="text"
id="given"
name="given"
defaultValue={existingName?.given?.[0] || ''}
required
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-transparent"
/>
</div>
<div>
<label htmlFor="family" className="block text-sm font-medium text-slate-700 mb-1">
Achternaam *
</label>
<input
type="text"
id="family"
name="family"
defaultValue={existingName?.family || ''}
required
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-transparent"
/>
</div>
</div>
{/* BSN and Birth Date */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label htmlFor="bsn" className="block text-sm font-medium text-slate-700 mb-1">
BSN *
</label>
<input
type="text"
id="bsn"
name="bsn"
defaultValue={existingBsn || ''}
required
placeholder="123456789"
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-transparent"
/>
</div>
<div>
<label htmlFor="birthDate" className="block text-sm font-medium text-slate-700 mb-1">
Geboortedatum *
</label>
<input
type="date"
id="birthDate"
name="birthDate"
defaultValue={patient?.birthDate || ''}
required
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-transparent"
/>
</div>
</div>
{/* Gender */}
<div>
<label htmlFor="gender" className="block text-sm font-medium text-slate-700 mb-1">
Geslacht *
</label>
<select
id="gender"
name="gender"
defaultValue={patient?.gender || 'unknown'}
required
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-transparent"
>
<option value="male">Man</option>
<option value="female">Vrouw</option>
<option value="other">Anders</option>
<option value="unknown">Onbekend</option>
</select>
</div>
{/* Contact Information */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label htmlFor="phone" className="block text-sm font-medium text-slate-700 mb-1">
Telefoonnummer
</label>
<input
type="tel"
id="phone"
name="phone"
defaultValue={existingPhone || ''}
placeholder="+31612345678"
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-transparent"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-slate-700 mb-1">
E-mail
</label>
<input
type="email"
id="email"
name="email"
defaultValue={existingEmail || ''}
placeholder="patient@example.com"
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-transparent"
/>
</div>
</div>
{/* Action Buttons */}
<div className="flex items-center gap-4 pt-4 border-t border-slate-200">
<button
type="submit"
disabled={isSubmitting}
className="inline-flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-teal-600 to-teal-700 hover:from-teal-700 hover:to-teal-800 text-white font-medium rounded-lg shadow-sm hover:shadow-md transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
<span>Opslaan...</span>
</>
) : (
<>
<Save className="h-4 w-4" />
<span>{patient ? 'Bijwerken' : 'Aanmaken'}</span>
</>
)}
</button>
<button
type="button"
onClick={() => router.back()}
className="px-4 py-2 text-slate-700 hover:text-slate-900 font-medium transition-colors"
>
Annuleren
</button>
</div>
</form>
);
}

View File

@@ -0,0 +1,142 @@
'use client';
/**
* Patient List Component (FHIR-based)
* Displays patients from FHIR API
*/
import { useState } from 'react';
import Link from 'next/link';
import { User, Calendar, Phone, Mail } from 'lucide-react';
import type { FHIRPatient } from '@/lib/fhir';
interface PatientListProps {
initialPatients: FHIRPatient[];
}
export function PatientList({ initialPatients }: PatientListProps) {
const [patients] = useState<FHIRPatient[]>(initialPatients);
if (patients.length === 0) {
return (
<div className="text-center py-12 bg-white rounded-lg border border-slate-200">
<User className="mx-auto h-12 w-12 text-slate-400" />
<h3 className="mt-4 text-lg font-medium text-slate-900">Geen patiënten gevonden</h3>
<p className="mt-2 text-sm text-slate-600">
Begin met het toevoegen van een nieuwe patiënt.
</p>
</div>
);
}
return (
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-slate-200">
<thead className="bg-slate-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
Patiënt
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
BSN
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
Geboortedatum
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
Contact
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
Geslacht
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-slate-200">
{patients.map((patient) => {
const name = patient.name?.[0];
const fullName = [
...(name?.prefix || []),
...(name?.given || []),
name?.family,
]
.filter(Boolean)
.join(' ');
const bsn = patient.identifier?.find(
(id) => id.system === 'http://fhir.nl/fhir/NamingSystem/bsn'
)?.value;
const phone = patient.telecom?.find((t) => t.system === 'phone')?.value;
const email = patient.telecom?.find((t) => t.system === 'email')?.value;
return (
<tr
key={patient.id}
className="hover:bg-slate-50 transition-colors cursor-pointer"
>
<td className="px-6 py-4 whitespace-nowrap">
<Link
href={`/epd/patients/${patient.id}`}
className="flex items-center group"
>
<div className="flex-shrink-0 h-10 w-10 bg-gradient-to-br from-teal-400 to-teal-600 rounded-full flex items-center justify-center">
<span className="text-white font-semibold text-sm">
{name?.given?.[0]?.[0]}
{name?.family?.[0]}
</span>
</div>
<div className="ml-4">
<div className="text-sm font-medium text-slate-900 group-hover:text-teal-600 transition-colors">
{fullName}
</div>
<div className="text-sm text-slate-500">ID: {patient.id}</div>
</div>
</Link>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-slate-900">{bsn || '-'}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center text-sm text-slate-900">
<Calendar className="h-4 w-4 text-slate-400 mr-2" />
{patient.birthDate || '-'}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="space-y-1">
{phone && (
<div className="flex items-center text-sm text-slate-900">
<Phone className="h-4 w-4 text-slate-400 mr-2" />
{phone}
</div>
)}
{email && (
<div className="flex items-center text-sm text-slate-900">
<Mail className="h-4 w-4 text-slate-400 mr-2" />
{email}
</div>
)}
{!phone && !email && (
<span className="text-sm text-slate-400">-</span>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm text-slate-900 capitalize">
{patient.gender === 'male' && 'Man'}
{patient.gender === 'female' && 'Vrouw'}
{patient.gender === 'other' && 'Anders'}
{patient.gender === 'unknown' && 'Onbekend'}
{!patient.gender && '-'}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,29 @@
import { PatientForm } from '../components/patient-form';
import Link from 'next/link';
import { ChevronLeft } from 'lucide-react';
export default function NewPatientPage() {
return (
<div className="px-4 sm:px-6 lg:px-8 py-8">
{/* Header with back button */}
<div className="mb-8">
<Link
href="/epd/patients"
className="inline-flex items-center gap-2 text-sm text-slate-600 hover:text-slate-900 mb-4 transition-colors"
>
<ChevronLeft className="h-4 w-4" />
<span>Terug naar patiënten</span>
</Link>
<h1 className="text-2xl font-bold text-slate-900">Nieuwe patiënt</h1>
<p className="text-sm text-slate-600 mt-1">
Voeg een nieuwe patiënt toe aan het systeem (FHIR)
</p>
</div>
{/* Patient Form */}
<div className="bg-white rounded-lg border border-slate-200 p-6">
<PatientForm />
</div>
</div>
);
}

52
app/epd/patients/page.tsx Normal file
View File

@@ -0,0 +1,52 @@
import { Suspense } from 'react';
import { Plus } from 'lucide-react';
import { getPatients } from './actions';
import { PatientList } from './components/patient-list';
import Link from 'next/link';
interface SearchParams {
search?: string;
}
export default async function PatientsPage({
searchParams,
}: {
searchParams: Promise<SearchParams>;
}) {
const params = await searchParams;
return (
<div className="px-4 sm:px-6 lg:px-8 py-8">
{/* Page Header */}
<div className="mb-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900">Patiënten (FHIR)</h1>
<p className="text-sm text-slate-600 mt-1">
FHIR-compliant patiëntenbeheer
</p>
</div>
<Link
href="/epd/patients/new"
className="inline-flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-teal-600 to-teal-700 hover:from-teal-700 hover:to-teal-800 text-white font-medium rounded-lg shadow-sm hover:shadow-md transition-all"
>
<Plus className="h-4 w-4" />
<span>Nieuwe patiënt</span>
</Link>
</div>
</div>
{/* Patient List */}
<Suspense fallback={<div>Loading patients...</div>}>
<PatientListWrapper searchParams={params} />
</Suspense>
</div>
);
}
async function PatientListWrapper({ searchParams }: { searchParams: SearchParams }) {
const patients = await getPatients({
search: searchParams.search,
});
return <PatientList initialPatients={patients} />;
}

View File

@@ -167,6 +167,10 @@
}
/* Global styles */
html {
scroll-behavior: smooth;
}
body {
font-family: var(--font-sans), system-ui, -apple-system, sans-serif;
}