feat(overdracht): E4 + E5 + E6 - Overdracht UI compleet
Epic 4 - Overdracht Overzicht: - /epd/overdracht pagina met patiënten grid - PatientCard component met alert badges - Filter tabs (Alle patiënten / Met alerts) - Responsive grid layout Epic 5 - Overdracht Detail: - /epd/overdracht/[patientId] pagina - VitalsBlock: vitale functies met interpretatie kleuren - ReportsBlock: rapportages laatste 24 uur - NursingLogsBlock: gemarkeerde dagnotities - RisksBlock: actieve risico's gesorteerd op niveau - AISummaryBlock: Claude AI samenvatting met bronverwijzingen Epic 6 - Integratie & Polish: - Overdracht link in EPD sidebar - Navigatie links dagregistratie <-> overdracht - Build en type check passed Bouwplan v1.3: alle 19 stories afgerond 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
301
app/epd/overdracht/[patientId]/page.tsx
Normal file
301
app/epd/overdracht/[patientId]/page.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* Overdracht Detail Page
|
||||
* E5.S1: Route /epd/overdracht/[patientId], patient header, 2-kolom layout
|
||||
*/
|
||||
|
||||
import { createClient } from '@/lib/auth/server';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { ArrowLeft, User, Calendar, Stethoscope, ClipboardList } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import type { PatientDetail } from '@/lib/types/overdracht';
|
||||
import type { NursingLog } from '@/lib/types/nursing-log';
|
||||
import { VitalsBlock } from './components/vitals-block';
|
||||
import { ReportsBlock } from './components/reports-block';
|
||||
import { NursingLogsBlock } from './components/nursing-logs-block';
|
||||
import { RisksBlock } from './components/risks-block';
|
||||
import { AISummaryBlock } from './components/ai-summary-block';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ patientId: string }>;
|
||||
}
|
||||
|
||||
async function getPatientDetail(patientId: string): Promise<PatientDetail | null> {
|
||||
const supabase = await createClient();
|
||||
|
||||
// Date calculations
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const todayStart = `${today}T00:00:00.000Z`;
|
||||
const last24h = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
// Parallel queries for all data
|
||||
const [
|
||||
patientResult,
|
||||
vitalsResult,
|
||||
reportsResult,
|
||||
logsResult,
|
||||
risksResult,
|
||||
conditionsResult,
|
||||
] = await Promise.all([
|
||||
// 1. Patient info
|
||||
supabase
|
||||
.from('patients')
|
||||
.select('id, name_given, name_family, name_prefix, birth_date, gender')
|
||||
.eq('id', patientId)
|
||||
.single(),
|
||||
|
||||
// 2. Vitals today
|
||||
supabase
|
||||
.from('observations')
|
||||
.select('id, code_display, value_quantity_value, value_quantity_unit, interpretation_code, effective_datetime')
|
||||
.eq('patient_id', patientId)
|
||||
.eq('category', 'vital-signs')
|
||||
.gte('effective_datetime', todayStart)
|
||||
.order('effective_datetime', { ascending: false }),
|
||||
|
||||
// 3. Reports last 24h
|
||||
supabase
|
||||
.from('reports')
|
||||
.select('id, type, content, created_at, created_by')
|
||||
.eq('patient_id', patientId)
|
||||
.gte('created_at', last24h)
|
||||
.is('deleted_at', null)
|
||||
.order('created_at', { ascending: false }),
|
||||
|
||||
// 4. Nursing logs today (all, not just marked)
|
||||
supabase
|
||||
.from('nursing_logs')
|
||||
.select('*')
|
||||
.eq('patient_id', patientId)
|
||||
.eq('shift_date', today)
|
||||
.order('timestamp', { ascending: false }),
|
||||
|
||||
// 5. Risks via intakes
|
||||
supabase
|
||||
.from('risk_assessments')
|
||||
.select('id, risk_type, risk_level, rationale, created_at, intakes!inner(patient_id)')
|
||||
.eq('intakes.patient_id', patientId)
|
||||
.in('risk_level', ['laag', 'gemiddeld', 'hoog', 'zeer_hoog']),
|
||||
|
||||
// 6. Active conditions
|
||||
supabase
|
||||
.from('conditions')
|
||||
.select('id, code_display, clinical_status, onset_datetime')
|
||||
.eq('patient_id', patientId)
|
||||
.eq('clinical_status', 'active'),
|
||||
]);
|
||||
|
||||
// Check if patient exists
|
||||
if (patientResult.error || !patientResult.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build response
|
||||
return {
|
||||
patient: {
|
||||
id: patientResult.data.id,
|
||||
name_given: patientResult.data.name_given,
|
||||
name_family: patientResult.data.name_family,
|
||||
name_prefix: patientResult.data.name_prefix || undefined,
|
||||
birth_date: patientResult.data.birth_date,
|
||||
gender: patientResult.data.gender,
|
||||
},
|
||||
vitals: (vitalsResult.data || []).map((v) => ({
|
||||
id: v.id,
|
||||
code_display: v.code_display,
|
||||
value_quantity_value: v.value_quantity_value,
|
||||
value_quantity_unit: v.value_quantity_unit,
|
||||
interpretation_code: v.interpretation_code,
|
||||
effective_datetime: v.effective_datetime,
|
||||
})),
|
||||
reports: (reportsResult.data || []).map((r) => ({
|
||||
id: r.id,
|
||||
type: r.type,
|
||||
content: r.content,
|
||||
created_at: r.created_at,
|
||||
created_by: r.created_by,
|
||||
})),
|
||||
nursingLogs: (logsResult.data || []) as NursingLog[],
|
||||
risks: (risksResult.data || []).map((r) => ({
|
||||
id: r.id,
|
||||
risk_type: r.risk_type,
|
||||
risk_level: r.risk_level,
|
||||
rationale: r.rationale,
|
||||
created_at: r.created_at,
|
||||
})),
|
||||
conditions: (conditionsResult.data || []).map((c) => ({
|
||||
id: c.id,
|
||||
code_display: c.code_display,
|
||||
clinical_status: c.clinical_status,
|
||||
onset_datetime: c.onset_datetime || undefined,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function formatPatientName(
|
||||
nameGiven: string[],
|
||||
nameFamily: string,
|
||||
namePrefix?: string
|
||||
): string {
|
||||
const given = nameGiven.join(' ');
|
||||
if (namePrefix) {
|
||||
return `${given} ${namePrefix} ${nameFamily}`;
|
||||
}
|
||||
return `${given} ${nameFamily}`;
|
||||
}
|
||||
|
||||
function calculateAge(birthDate: string): number {
|
||||
const birth = new Date(birthDate);
|
||||
const today = new Date();
|
||||
let age = today.getFullYear() - birth.getFullYear();
|
||||
const monthDiff = today.getMonth() - birth.getMonth();
|
||||
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) {
|
||||
age--;
|
||||
}
|
||||
return age;
|
||||
}
|
||||
|
||||
function getGenderLabel(gender: string): string {
|
||||
switch (gender) {
|
||||
case 'male': return 'Man';
|
||||
case 'female': return 'Vrouw';
|
||||
default: return 'Onbekend';
|
||||
}
|
||||
}
|
||||
|
||||
export default async function OverdrachtDetailPage({ params }: PageProps) {
|
||||
const { patientId } = await params;
|
||||
const data = await getPatientDetail(patientId);
|
||||
|
||||
if (!data) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { patient, vitals, reports, nursingLogs, risks, conditions } = data;
|
||||
const patientName = formatPatientName(
|
||||
patient.name_given,
|
||||
patient.name_family,
|
||||
patient.name_prefix
|
||||
);
|
||||
const age = calculateAge(patient.birth_date);
|
||||
const genderLabel = getGenderLabel(patient.gender);
|
||||
|
||||
// Get primary diagnosis if available
|
||||
const primaryDiagnosis = conditions.length > 0 ? conditions[0].code_display : null;
|
||||
|
||||
// Format today's date
|
||||
const displayDate = new Date().toLocaleDateString('nl-NL', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
});
|
||||
|
||||
// Count alerts
|
||||
const markedLogs = nursingLogs.filter(l => l.include_in_handover);
|
||||
const highRisks = risks.filter(r => r.risk_level === 'hoog' || r.risk_level === 'zeer_hoog');
|
||||
const abnormalVitals = vitals.filter(v => v.interpretation_code && ['H', 'L', 'HH', 'LL'].includes(v.interpretation_code));
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
{/* Header */}
|
||||
<div className="bg-white border-b border-slate-200">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
{/* Navigation links */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<Link
|
||||
href="/epd/overdracht"
|
||||
className="inline-flex items-center gap-2 text-sm text-slate-600 hover:text-teal-600 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Terug naar overzicht
|
||||
</Link>
|
||||
<Link
|
||||
href={`/epd/dagregistratie/${patientId}`}
|
||||
className="inline-flex items-center gap-2 text-sm text-amber-600 hover:text-amber-700 font-medium transition-colors"
|
||||
>
|
||||
<ClipboardList className="h-4 w-4" />
|
||||
Dagregistratie
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Patient header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-14 h-14 bg-teal-100 rounded-full flex items-center justify-center">
|
||||
<User className="h-7 w-7 text-teal-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">{patientName}</h1>
|
||||
<div className="flex items-center gap-4 mt-1 text-sm text-slate-600">
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-4 w-4" />
|
||||
{age} jaar • {genderLabel}
|
||||
</span>
|
||||
{primaryDiagnosis && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Stethoscope className="h-4 w-4" />
|
||||
{primaryDiagnosis}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alert summary badges */}
|
||||
<div className="flex items-center gap-2">
|
||||
{highRisks.length > 0 && (
|
||||
<span className="px-3 py-1 bg-red-100 text-red-700 rounded-full text-sm font-medium">
|
||||
{highRisks.length} hoog risico
|
||||
</span>
|
||||
)}
|
||||
{abnormalVitals.length > 0 && (
|
||||
<span className="px-3 py-1 bg-orange-100 text-orange-700 rounded-full text-sm font-medium">
|
||||
{abnormalVitals.length} afwijkend
|
||||
</span>
|
||||
)}
|
||||
{markedLogs.length > 0 && (
|
||||
<span className="px-3 py-1 bg-blue-100 text-blue-700 rounded-full text-sm font-medium">
|
||||
{markedLogs.length} notitie
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date indicator */}
|
||||
<div className="mt-4 pt-4 border-t border-slate-100">
|
||||
<p className="text-sm text-slate-500">
|
||||
Overdracht voor {displayDate}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content - 2 column layout */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left column - Info blocks (scrollable) */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
{/* E5.S2: VitalsBlock */}
|
||||
<VitalsBlock vitals={vitals} />
|
||||
|
||||
{/* E5.S2: ReportsBlock */}
|
||||
<ReportsBlock reports={reports} />
|
||||
|
||||
{/* E5.S3: NursingLogsBlock */}
|
||||
<NursingLogsBlock logs={nursingLogs} />
|
||||
|
||||
{/* E5.S3: RisksBlock */}
|
||||
<RisksBlock risks={risks} />
|
||||
</div>
|
||||
|
||||
{/* Right column - AI Summary (sticky) */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className="lg:sticky lg:top-6">
|
||||
{/* E5.S4: AISummaryBlock */}
|
||||
<AISummaryBlock patientId={patientId} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user