FHIR, API, datamodel
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user