FHIR, API, datamodel
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
163
app/api/fhir/Patient/[id]/route.ts
Normal file
163
app/api/fhir/Patient/[id]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
148
app/api/fhir/Patient/route.ts
Normal file
148
app/api/fhir/Patient/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
56
app/api/fhir/Practitioner/[id]/route.ts
Normal file
56
app/api/fhir/Practitioner/[id]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
142
app/api/fhir/Practitioner/route.ts
Normal file
142
app/api/fhir/Practitioner/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
46
app/epd/patients/[id]/page.tsx
Normal file
46
app/epd/patients/[id]/page.tsx
Normal 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
117
app/epd/patients/actions.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
254
app/epd/patients/components/patient-form.tsx
Normal file
254
app/epd/patients/components/patient-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
142
app/epd/patients/components/patient-list.tsx
Normal file
142
app/epd/patients/components/patient-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
app/epd/patients/new/page.tsx
Normal file
29
app/epd/patients/new/page.tsx
Normal 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
52
app/epd/patients/page.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -167,6 +167,10 @@
|
||||
}
|
||||
|
||||
/* Global styles */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans), system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user