feat(overdracht): E2 + E3 - API overdracht en dagregistratie UI

Epic 2 - API Overdracht:
- GET /api/overdracht/patients (patiënten met alerts)
- GET /api/overdracht/[patientId] (detail met vitals, reports, logs, risks)
- POST /api/overdracht/generate (AI samenvatting met bronverwijzingen)
- lib/ai/overdracht-prompt.ts (system + user prompt)
- lib/types/overdracht.ts (TypeScript types)

Epic 3 - Dagregistratie UI:
- /epd/dagregistratie/[patientId] pagina
- Quick entry form (categorie, tijd, tekst, overdracht checkbox)
- Edit/Delete functionaliteit met inline editing
- Confirm delete dialog
- Summary cards (totaal, overdracht, incidenten)

Bouwplan bijgewerkt naar v1.2 (10/19 stories voltooid)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
colinislit
2025-12-06 00:18:06 +01:00
parent 08cffd0245
commit d622ac8035
9 changed files with 1824 additions and 16 deletions

View File

@@ -0,0 +1,181 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { createClient } from '@/lib/auth/server';
import type {
PatientDetail,
VitalSign,
Report,
RiskAssessment,
Condition,
} from '@/lib/types/overdracht';
import type { NursingLog } from '@/lib/types/nursing-log';
interface RouteParams {
params: Promise<{ patientId: string }>;
}
export async function GET(request: NextRequest, { params }: RouteParams) {
try {
const { patientId } = await params;
if (!z.string().uuid().safeParse(patientId).success) {
return NextResponse.json(
{ error: 'patientId moet een geldige UUID zijn' },
{ status: 400 }
);
}
const supabase = await createClient();
// 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 NextResponse.json(
{ error: 'Patiënt niet gevonden' },
{ status: 404 }
);
}
// Handle errors for other queries gracefully (return empty arrays)
if (vitalsResult.error) {
console.error('Error fetching vitals:', vitalsResult.error);
}
if (reportsResult.error) {
console.error('Error fetching reports:', reportsResult.error);
}
if (logsResult.error) {
console.error('Error fetching nursing logs:', logsResult.error);
}
if (risksResult.error) {
console.error('Error fetching risks:', risksResult.error);
}
if (conditionsResult.error) {
console.error('Error fetching conditions:', conditionsResult.error);
}
// Map vitals
const vitals: VitalSign[] = (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,
}));
// Map reports
const reports: Report[] = (reportsResult.data || []).map((r) => ({
id: r.id,
type: r.type,
content: r.content,
created_at: r.created_at,
created_by: r.created_by,
}));
// Nursing logs (already typed correctly from database)
const nursingLogs: NursingLog[] = logsResult.data || [];
// Map risks (remove the intakes join data)
const risks: RiskAssessment[] = (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,
}));
// Map conditions
const conditions: Condition[] = (conditionsResult.data || []).map((c) => ({
id: c.id,
code_display: c.code_display,
clinical_status: c.clinical_status,
onset_datetime: c.onset_datetime || undefined,
}));
// Build response
const response: PatientDetail = {
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,
reports,
nursingLogs,
risks,
conditions,
};
return NextResponse.json(response);
} catch (error) {
console.error('Unexpected error in GET /api/overdracht/[patientId]:', error);
return NextResponse.json(
{ error: 'Onverwachte serverfout' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,329 @@
/**
* Overdracht Generate API
*
* POST /api/overdracht/generate
* Genereert een overdracht samenvatting met Claude AI
*/
import { createClient } from '@/lib/auth/server';
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import {
OVERDRACHT_SYSTEM_PROMPT,
buildOverdrachtUserPrompt,
calculateAge,
formatPatientName,
type OverdrachtContext,
} from '@/lib/ai/overdracht-prompt';
import {
GenerateOverdrachtSchema,
type AISamenvatting,
type Aandachtspunt,
} from '@/lib/types/overdracht';
import type { NursingLog } from '@/lib/types/nursing-log';
// Zod schema for AI response validation
const AandachtspuntSchema = z.object({
tekst: z.string(),
urgent: z.boolean(),
bron: z.object({
type: z.enum(['observatie', 'rapportage', 'dagnotitie', 'risico']),
id: z.string(),
datum: z.string(),
label: z.string(),
}),
});
const AIResponseSchema = z.object({
samenvatting: z.string(),
aandachtspunten: z.array(AandachtspuntSchema).max(5),
actiepunten: z.array(z.string()).max(3),
});
/**
* Load context from database
*/
async function loadOverdrachtContext(
supabase: Awaited<ReturnType<typeof createClient>>,
patientId: string
): Promise<OverdrachtContext> {
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
const [
patientResult,
vitalsResult,
reportsResult,
logsResult,
risksResult,
conditionsResult,
] = await Promise.all([
supabase
.from('patients')
.select('id, name_given, name_family, name_prefix, birth_date, gender')
.eq('id', patientId)
.single(),
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 }),
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 }),
supabase
.from('nursing_logs')
.select('*')
.eq('patient_id', patientId)
.eq('shift_date', today)
.order('timestamp', { ascending: false }),
supabase
.from('risk_assessments')
.select('id, risk_type, risk_level, rationale, created_at, intakes!inner(patient_id)')
.eq('intakes.patient_id', patientId),
supabase
.from('conditions')
.select('id, code_display, clinical_status, onset_datetime')
.eq('patient_id', patientId)
.eq('clinical_status', 'active'),
]);
if (patientResult.error || !patientResult.data) {
throw new Error('Patiënt niet gevonden');
}
const patient = patientResult.data;
return {
patientId,
patientName: formatPatientName(
patient.name_given,
patient.name_family,
patient.name_prefix || undefined
),
age: calculateAge(patient.birth_date),
gender: patient.gender,
conditions: (conditionsResult.data || []).map((c) => ({
id: c.id,
code_display: c.code_display,
clinical_status: c.clinical_status,
onset_datetime: c.onset_datetime || undefined,
})),
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,
})),
};
}
/**
* Call Claude API
*/
async function callClaudeAPI(context: OverdrachtContext): Promise<{
samenvatting: string;
aandachtspunten: Aandachtspunt[];
actiepunten: string[];
}> {
const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) {
throw new Error('ANTHROPIC_API_KEY ontbreekt in environment');
}
const userPrompt = buildOverdrachtUserPrompt(context);
const response = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: 'claude-sonnet-4-20250514',
max_tokens: 2048,
temperature: 0.3,
system: OVERDRACHT_SYSTEM_PROMPT,
messages: [{ role: 'user', content: userPrompt }],
}),
});
if (!response.ok) {
const errorBody = await response.text();
console.error('Claude API error:', errorBody);
throw new Error(`Claude API fout: ${response.status}`);
}
const data = await response.json();
const rawText = data?.content?.[0]?.text;
if (!rawText) {
throw new Error('Geen response van Claude API');
}
// Parse JSON from response (handle potential markdown code blocks)
let jsonText = rawText.trim();
if (jsonText.startsWith('```json')) {
jsonText = jsonText.slice(7);
}
if (jsonText.startsWith('```')) {
jsonText = jsonText.slice(3);
}
if (jsonText.endsWith('```')) {
jsonText = jsonText.slice(0, -3);
}
const parsed = JSON.parse(jsonText.trim());
// Validate with Zod schema
const validated = AIResponseSchema.parse(parsed);
return validated;
}
/**
* Log AI event to database
*/
async function logAIEvent(
supabase: Awaited<ReturnType<typeof createClient>>,
patientId: string,
context: OverdrachtContext,
result: { samenvatting: string; aandachtspunten: Aandachtspunt[]; actiepunten: string[] },
durationMs: number
) {
try {
await supabase.from('ai_events').insert({
kind: 'overdracht_generate',
patient_id: patientId,
input_data: {
vitalCount: context.vitals.length,
reportCount: context.reports.length,
logCount: context.nursingLogs.length,
riskCount: context.risks.length,
conditionCount: context.conditions.length,
},
output_data: {
aandachtspuntenCount: result.aandachtspunten.length,
actiepuntenCount: result.actiepunten.length,
urgentCount: result.aandachtspunten.filter((a) => a.urgent).length,
},
duration_ms: durationMs,
});
} catch (error) {
// Log but don't fail the request
console.error('Failed to log AI event:', error);
}
}
/**
* POST /api/overdracht/generate
*/
export async function POST(request: NextRequest) {
const startTime = Date.now();
try {
const body = await request.json();
// Validate input
const result = GenerateOverdrachtSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{
error: 'Validatiefout',
details: result.error.issues.map((e) => ({
field: e.path.join('.'),
message: e.message,
})),
},
{ status: 400 }
);
}
const { patientId } = result.data;
const supabase = await createClient();
// Check auth
const { data: authData } = await supabase.auth.getUser();
if (!authData?.user) {
return NextResponse.json({ error: 'Niet geautoriseerd' }, { status: 401 });
}
// Load context
const context = await loadOverdrachtContext(supabase, patientId);
// Call Claude API
const aiResult = await callClaudeAPI(context);
const durationMs = Date.now() - startTime;
// Log AI event
await logAIEvent(supabase, patientId, context, aiResult, durationMs);
// Build response
const response: AISamenvatting = {
samenvatting: aiResult.samenvatting,
aandachtspunten: aiResult.aandachtspunten,
actiepunten: aiResult.actiepunten,
generatedAt: new Date().toISOString(),
durationMs,
};
return NextResponse.json(response);
} catch (error) {
console.error('Error generating overdracht:', error);
const errorMessage = error instanceof Error ? error.message : 'Onbekende fout';
if (errorMessage.includes('Patiënt niet gevonden')) {
return NextResponse.json({ error: errorMessage }, { status: 404 });
}
if (errorMessage.includes('ANTHROPIC_API_KEY')) {
return NextResponse.json(
{ error: 'AI service niet geconfigureerd' },
{ status: 503 }
);
}
if (errorMessage.includes('Claude API')) {
return NextResponse.json(
{ error: 'AI service tijdelijk niet beschikbaar', details: errorMessage },
{ status: 503 }
);
}
return NextResponse.json(
{
error: 'Fout bij genereren overdracht',
details: process.env.NODE_ENV === 'development' ? errorMessage : undefined,
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,206 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/auth/server';
import type { PatientOverzicht, PatientOverzichtResponse } from '@/lib/types/overdracht';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const dateParam = searchParams.get('date');
// Use provided date or today
const targetDate = dateParam || new Date().toISOString().split('T')[0];
// Validate date format
if (!/^\d{4}-\d{2}-\d{2}$/.test(targetDate)) {
return NextResponse.json(
{ error: 'date moet in YYYY-MM-DD formaat zijn' },
{ status: 400 }
);
}
const supabase = await createClient();
// Get start and end of day for date filtering
const dayStart = `${targetDate}T00:00:00.000Z`;
const dayEnd = `${targetDate}T23:59:59.999Z`;
// 1. Get patients with encounters today
const { data: encounterData, error: encounterError } = await supabase
.from('encounters')
.select(`
patient_id,
patients!inner (
id,
name_given,
name_family,
birth_date,
gender
)
`)
.gte('period_start', dayStart)
.lte('period_start', dayEnd)
.in('status', ['planned', 'in-progress', 'completed']);
if (encounterError) {
console.error('Error fetching encounters:', encounterError);
return NextResponse.json(
{ error: 'Fout bij ophalen patiënten', details: encounterError.message },
{ status: 500 }
);
}
// Deduplicate patients (one patient may have multiple encounters)
const patientMap = new Map<string, {
id: string;
name_given: string[];
name_family: string;
birth_date: string;
gender: string;
}>();
for (const encounter of encounterData || []) {
const patient = encounter.patients as unknown as {
id: string;
name_given: string[];
name_family: string;
birth_date: string;
gender: string;
};
if (patient && !patientMap.has(patient.id)) {
patientMap.set(patient.id, patient);
}
}
const patientIds = Array.from(patientMap.keys());
if (patientIds.length === 0) {
const response: PatientOverzichtResponse = {
patients: [],
total: 0,
date: targetDate,
};
return NextResponse.json(response);
}
// 2. Get alert counts in parallel
const [
{ data: risksData },
{ data: vitalsData },
{ data: logsData },
] = await Promise.all([
// High risk assessments (via intakes)
supabase
.from('risk_assessments')
.select('id, intakes!inner(patient_id)')
.in('intakes.patient_id', patientIds)
.in('risk_level', ['hoog', 'zeer_hoog']),
// Abnormal vitals today
supabase
.from('observations')
.select('id, patient_id, interpretation_code')
.in('patient_id', patientIds)
.eq('category', 'vital-signs')
.gte('effective_datetime', dayStart)
.lte('effective_datetime', dayEnd)
.in('interpretation_code', ['H', 'L', 'HH', 'LL']),
// Marked nursing logs for handover
supabase
.from('nursing_logs')
.select('id, patient_id')
.in('patient_id', patientIds)
.eq('shift_date', targetDate)
.eq('include_in_handover', true),
]);
// Count alerts per patient
const alertCounts = new Map<string, {
high_risk_count: number;
abnormal_vitals_count: number;
marked_logs_count: number;
}>();
// Initialize all patients with zero counts
for (const patientId of patientIds) {
alertCounts.set(patientId, {
high_risk_count: 0,
abnormal_vitals_count: 0,
marked_logs_count: 0,
});
}
// Count high risks
for (const risk of risksData || []) {
const intake = risk.intakes as unknown as { patient_id: string };
if (intake?.patient_id) {
const counts = alertCounts.get(intake.patient_id);
if (counts) counts.high_risk_count++;
}
}
// Count abnormal vitals
for (const vital of vitalsData || []) {
if (vital.patient_id) {
const counts = alertCounts.get(vital.patient_id);
if (counts) counts.abnormal_vitals_count++;
}
}
// Count marked logs
for (const log of logsData || []) {
if (log.patient_id) {
const counts = alertCounts.get(log.patient_id);
if (counts) counts.marked_logs_count++;
}
}
// 3. Build response
const patients: PatientOverzicht[] = Array.from(patientMap.values()).map(
(patient) => {
const alerts = alertCounts.get(patient.id) || {
high_risk_count: 0,
abnormal_vitals_count: 0,
marked_logs_count: 0,
};
return {
id: patient.id,
name_given: patient.name_given,
name_family: patient.name_family,
birth_date: patient.birth_date,
gender: patient.gender,
alerts: {
...alerts,
total:
alerts.high_risk_count +
alerts.abnormal_vitals_count +
alerts.marked_logs_count,
},
};
}
);
// Sort by total alerts (descending), then by name
patients.sort((a, b) => {
if (b.alerts.total !== a.alerts.total) {
return b.alerts.total - a.alerts.total;
}
return a.name_family.localeCompare(b.name_family);
});
const response: PatientOverzichtResponse = {
patients,
total: patients.length,
date: targetDate,
};
return NextResponse.json(response);
} catch (error) {
console.error('Unexpected error in GET /api/overdracht/patients:', error);
return NextResponse.json(
{ error: 'Onverwachte serverfout' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,231 @@
'use client';
/**
* LogForm Component
* E3.S2: Quick entry form met categorie, tijd, tekst en overdracht checkbox
*/
import { useState, useTransition } from 'react';
import { format } from 'date-fns';
import {
Loader2,
Plus,
Pill,
Utensils,
User,
AlertTriangle,
FileText,
} from 'lucide-react';
import {
NURSING_LOG_CATEGORIES,
CATEGORY_CONFIG,
type NursingLogCategory,
} from '@/lib/types/nursing-log';
interface LogFormProps {
patientId: string;
onSuccess: () => void;
}
// Icon mapping
const CATEGORY_ICONS: Record<NursingLogCategory, React.ComponentType<{ className?: string }>> = {
medicatie: Pill,
adl: Utensils,
gedrag: User,
incident: AlertTriangle,
observatie: FileText,
};
export function LogForm({ patientId, onSuccess }: LogFormProps) {
const [category, setCategory] = useState<NursingLogCategory>('observatie');
const [content, setContent] = useState('');
const [time, setTime] = useState(format(new Date(), 'HH:mm'));
const [includeInHandover, setIncludeInHandover] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!content.trim()) {
setError('Vul een notitie in');
return;
}
if (content.length > 500) {
setError('Notitie mag maximaal 500 karakters bevatten');
return;
}
setError(null);
// Build timestamp from date and time
const today = new Date();
const [hours, minutes] = time.split(':').map(Number);
today.setHours(hours, minutes, 0, 0);
const timestamp = today.toISOString();
startTransition(async () => {
try {
const response = await fetch('/api/nursing-logs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
patient_id: patientId,
category,
content: content.trim(),
timestamp,
include_in_handover: includeInHandover,
}),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Opslaan mislukt');
}
// Reset form
setContent('');
setTime(format(new Date(), 'HH:mm'));
setIncludeInHandover(false);
setCategory('observatie');
onSuccess();
} catch (err) {
console.error('Failed to create log:', err);
setError(err instanceof Error ? err.message : 'Opslaan mislukt');
}
});
};
const charactersLeft = 500 - content.length;
const selectedConfig = CATEGORY_CONFIG[category];
return (
<form
onSubmit={handleSubmit}
className="bg-white rounded-lg border border-slate-200 overflow-hidden"
>
<div className="px-4 py-3 bg-slate-50 border-b border-slate-200">
<h2 className="font-semibold text-slate-900">Nieuwe notitie</h2>
</div>
<div className="p-4 space-y-4">
{/* Category Selection */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Categorie
</label>
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2">
{NURSING_LOG_CATEGORIES.map((cat) => {
const config = CATEGORY_CONFIG[cat];
const Icon = CATEGORY_ICONS[cat];
const isSelected = category === cat;
return (
<button
key={cat}
type="button"
onClick={() => setCategory(cat)}
className={`flex flex-col items-center gap-1 p-3 rounded-lg border-2 transition-all ${
isSelected
? `${config.bgColor} ${config.textColor} border-current`
: 'border-slate-200 hover:border-slate-300 text-slate-600'
}`}
>
<Icon className="h-5 w-5" />
<span className="text-xs font-medium">{config.label}</span>
</button>
);
})}
</div>
</div>
{/* Time Input */}
<div>
<label
htmlFor="time"
className="block text-sm font-medium text-slate-700 mb-2"
>
Tijdstip
</label>
<input
type="time"
id="time"
value={time}
onChange={(e) => setTime(e.target.value)}
className="w-full sm:w-32 rounded-lg border border-slate-200 px-3 py-2 text-sm focus:border-teal-500 focus:ring-2 focus:ring-teal-100 outline-none"
/>
</div>
{/* Content Textarea */}
<div>
<label
htmlFor="content"
className="block text-sm font-medium text-slate-700 mb-2"
>
Notitie
</label>
<textarea
id="content"
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={`Beschrijf de ${selectedConfig.label.toLowerCase()}...`}
rows={3}
maxLength={500}
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm focus:border-teal-500 focus:ring-2 focus:ring-teal-100 outline-none resize-none"
/>
<div className="flex justify-between mt-1">
<span
className={`text-xs ${
charactersLeft < 50 ? 'text-amber-600' : 'text-slate-500'
}`}
>
{charactersLeft} karakters over
</span>
</div>
</div>
{/* Include in Handover Checkbox */}
<div className="flex items-center gap-3 p-3 bg-teal-50 rounded-lg border border-teal-200">
<input
type="checkbox"
id="handover"
checked={includeInHandover}
onChange={(e) => setIncludeInHandover(e.target.checked)}
className="w-4 h-4 rounded border-slate-300 text-teal-600 focus:ring-teal-500"
/>
<label htmlFor="handover" className="flex-1 cursor-pointer">
<span className="text-sm font-medium text-teal-900">
Opnemen in overdracht
</span>
<p className="text-xs text-teal-700">
Deze notitie wordt meegenomen in de AI-gegenereerde overdracht
</p>
</label>
</div>
{/* Error Message */}
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
<p className="text-sm text-red-700">{error}</p>
</div>
)}
{/* Submit Button */}
<button
type="submit"
disabled={isPending || !content.trim()}
className="w-full flex items-center justify-center gap-2 rounded-lg bg-teal-600 px-4 py-3 text-sm font-medium text-white hover:bg-teal-700 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Plus className="h-4 w-4" />
)}
{isPending ? 'Opslaan...' : 'Notitie toevoegen'}
</button>
</div>
</form>
);
}

View File

@@ -0,0 +1,397 @@
'use client';
/**
* LogList Component
* E3.S1: Lijst van dagnotities met real-time updates
* E3.S2: Inclusief quick entry form
* E3.S3: Edit/Delete functionality
*/
import { useState, useCallback, useTransition } from 'react';
import { format } from 'date-fns';
import { nl } from 'date-fns/locale';
import {
Pill,
Utensils,
User,
AlertTriangle,
FileText,
Clock,
CheckCircle2,
Pencil,
Trash2,
X,
Check,
Loader2,
} from 'lucide-react';
import type { NursingLog, NursingLogCategory } from '@/lib/types/nursing-log';
import { CATEGORY_CONFIG, NURSING_LOG_CATEGORIES } from '@/lib/types/nursing-log';
import { LogForm } from './log-form';
interface LogListProps {
patientId: string;
initialLogs: NursingLog[];
date: string;
}
// Icon mapping
const CATEGORY_ICONS: Record<NursingLogCategory, React.ComponentType<{ className?: string }>> = {
medicatie: Pill,
adl: Utensils,
gedrag: User,
incident: AlertTriangle,
observatie: FileText,
};
export function LogList({ patientId, initialLogs, date }: LogListProps) {
const [logs, setLogs] = useState<NursingLog[]>(initialLogs);
// Refresh logs from API
const refreshLogs = useCallback(async () => {
try {
const response = await fetch(
`/api/nursing-logs?patientId=${patientId}&date=${date}`
);
if (response.ok) {
const data = await response.json();
setLogs(data.logs);
}
} catch (error) {
console.error('Failed to refresh logs:', error);
}
}, [patientId, date]);
// Group logs by category for summary
const logsByCategory = logs.reduce(
(acc, log) => {
acc[log.category] = (acc[log.category] || 0) + 1;
return acc;
},
{} as Record<string, number>
);
const markedForHandover = logs.filter((l) => l.include_in_handover).length;
return (
<div className="space-y-6">
{/* Quick Entry Form */}
<LogForm patientId={patientId} onSuccess={refreshLogs} />
{/* Summary Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-white rounded-lg border border-slate-200 p-4">
<div className="text-2xl font-bold text-slate-900">{logs.length}</div>
<div className="text-sm text-slate-600">Notities vandaag</div>
</div>
<div className="bg-white rounded-lg border border-slate-200 p-4">
<div className="flex items-center gap-2">
<CheckCircle2 className="h-5 w-5 text-teal-600" />
<span className="text-2xl font-bold text-slate-900">
{markedForHandover}
</span>
</div>
<div className="text-sm text-slate-600">Voor overdracht</div>
</div>
{logsByCategory['incident'] > 0 && (
<div className="bg-red-50 rounded-lg border border-red-200 p-4">
<div className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-red-600" />
<span className="text-2xl font-bold text-red-700">
{logsByCategory['incident']}
</span>
</div>
<div className="text-sm text-red-600">Incidenten</div>
</div>
)}
</div>
{/* Log List */}
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
<div className="px-4 py-3 bg-slate-50 border-b border-slate-200">
<h2 className="font-semibold text-slate-900">
Notities ({logs.length})
</h2>
</div>
{logs.length === 0 ? (
<div className="p-8 text-center">
<FileText className="h-12 w-12 text-slate-300 mx-auto mb-3" />
<p className="text-slate-600 mb-1">Nog geen notities vandaag</p>
<p className="text-sm text-slate-500">
Voeg een notitie toe via het formulier hieronder
</p>
</div>
) : (
<div className="divide-y divide-slate-100">
{logs.map((log) => (
<LogCard key={log.id} log={log} onUpdate={refreshLogs} />
))}
</div>
)}
</div>
</div>
);
}
interface LogCardProps {
log: NursingLog;
onUpdate: () => void;
}
function LogCard({ log, onUpdate }: LogCardProps) {
const [isEditing, setIsEditing] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [editContent, setEditContent] = useState(log.content);
const [editCategory, setEditCategory] = useState<NursingLogCategory>(
log.category as NursingLogCategory
);
const [editHandover, setEditHandover] = useState(log.include_in_handover);
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const config = CATEGORY_CONFIG[log.category as NursingLogCategory];
const Icon = CATEGORY_ICONS[log.category as NursingLogCategory] || FileText;
const handleSave = () => {
if (!editContent.trim()) {
setError('Notitie mag niet leeg zijn');
return;
}
setError(null);
startTransition(async () => {
try {
const response = await fetch(`/api/nursing-logs/${log.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: editContent.trim(),
category: editCategory,
include_in_handover: editHandover,
}),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Opslaan mislukt');
}
setIsEditing(false);
onUpdate();
} catch (err) {
setError(err instanceof Error ? err.message : 'Opslaan mislukt');
}
});
};
const handleDelete = () => {
startTransition(async () => {
try {
const response = await fetch(`/api/nursing-logs/${log.id}`, {
method: 'DELETE',
});
if (!response.ok && response.status !== 204) {
const data = await response.json();
throw new Error(data.error || 'Verwijderen mislukt');
}
setShowDeleteConfirm(false);
onUpdate();
} catch (err) {
setError(err instanceof Error ? err.message : 'Verwijderen mislukt');
}
});
};
const handleCancelEdit = () => {
setIsEditing(false);
setEditContent(log.content);
setEditCategory(log.category as NursingLogCategory);
setEditHandover(log.include_in_handover);
setError(null);
};
// Delete confirmation dialog
if (showDeleteConfirm) {
return (
<div className="p-4 bg-red-50 border-b border-red-100">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 bg-red-100">
<Trash2 className="h-5 w-5 text-red-600" />
</div>
<div className="flex-1">
<p className="text-sm font-medium text-red-900 mb-1">
Notitie verwijderen?
</p>
<p className="text-xs text-red-700 mb-3">
Deze actie kan niet ongedaan worden gemaakt.
</p>
<div className="flex items-center gap-2">
<button
onClick={handleDelete}
disabled={isPending}
className="inline-flex items-center gap-1 px-3 py-1.5 bg-red-600 text-white text-sm font-medium rounded-md hover:bg-red-700 disabled:opacity-60"
>
{isPending ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Trash2 className="h-3 w-3" />
)}
Verwijderen
</button>
<button
onClick={() => setShowDeleteConfirm(false)}
disabled={isPending}
className="px-3 py-1.5 text-sm font-medium text-red-700 hover:text-red-900"
>
Annuleren
</button>
</div>
</div>
</div>
</div>
);
}
// Edit mode
if (isEditing) {
return (
<div className="p-4 bg-amber-50 border-b border-amber-100">
<div className="space-y-3">
{/* Category selector */}
<div className="flex flex-wrap gap-1">
{NURSING_LOG_CATEGORIES.map((cat) => {
const catConfig = CATEGORY_CONFIG[cat];
const isSelected = editCategory === cat;
return (
<button
key={cat}
type="button"
onClick={() => setEditCategory(cat)}
className={`text-xs font-medium px-2 py-1 rounded-full transition-colors ${
isSelected
? `${catConfig.bgColor} ${catConfig.textColor}`
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
{catConfig.label}
</button>
);
})}
</div>
{/* Content textarea */}
<textarea
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
rows={3}
maxLength={500}
className="w-full rounded-lg border border-amber-200 px-3 py-2 text-sm focus:border-amber-400 focus:ring-2 focus:ring-amber-100 outline-none resize-none"
/>
{/* Handover checkbox */}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={editHandover}
onChange={(e) => setEditHandover(e.target.checked)}
className="w-4 h-4 rounded border-slate-300 text-teal-600 focus:ring-teal-500"
/>
<span className="text-sm text-slate-700">
Opnemen in overdracht
</span>
</label>
{/* Error message */}
{error && (
<p className="text-xs text-red-600">{error}</p>
)}
{/* Action buttons */}
<div className="flex items-center gap-2">
<button
onClick={handleSave}
disabled={isPending}
className="inline-flex items-center gap-1 px-3 py-1.5 bg-teal-600 text-white text-sm font-medium rounded-md hover:bg-teal-700 disabled:opacity-60"
>
{isPending ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Check className="h-3 w-3" />
)}
Opslaan
</button>
<button
onClick={handleCancelEdit}
disabled={isPending}
className="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-slate-600 hover:text-slate-900"
>
<X className="h-3 w-3" />
Annuleren
</button>
</div>
</div>
</div>
);
}
// Normal view
return (
<div className="p-4 hover:bg-slate-50 transition-colors group">
<div className="flex items-start gap-3">
{/* Category Icon */}
<div
className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 ${config?.bgColor || 'bg-gray-100'}`}
>
<Icon className={`h-5 w-5 ${config?.textColor || 'text-gray-600'}`} />
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span
className={`text-xs font-medium px-2 py-0.5 rounded-full ${config?.bgColor || 'bg-gray-100'} ${config?.textColor || 'text-gray-700'}`}
>
{config?.label || log.category}
</span>
{log.include_in_handover && (
<span className="text-xs font-medium px-2 py-0.5 rounded-full bg-teal-100 text-teal-700">
Overdracht
</span>
)}
</div>
<p className="text-sm text-slate-900 whitespace-pre-wrap">
{log.content}
</p>
<div className="flex items-center gap-3 mt-2 text-xs text-slate-500">
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{format(new Date(log.timestamp), 'HH:mm', { locale: nl })}
</span>
</div>
</div>
{/* Action buttons */}
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => setIsEditing(true)}
className="p-1.5 rounded-md text-slate-400 hover:text-slate-600 hover:bg-slate-100"
title="Bewerken"
>
<Pencil className="h-4 w-4" />
</button>
<button
onClick={() => setShowDeleteConfirm(true)}
className="p-1.5 rounded-md text-slate-400 hover:text-red-600 hover:bg-red-50"
title="Verwijderen"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,134 @@
/**
* Dagregistratie Page
* E3.S1: Route /epd/dagregistratie/[patientId], lijst van notities vandaag
*/
import { createClient } from '@/lib/auth/server';
import { notFound } from 'next/navigation';
import { LogList } from './components/log-list';
import { ArrowLeft, ClipboardList } from 'lucide-react';
import Link from 'next/link';
import type { NursingLog } from '@/lib/types/nursing-log';
interface PageProps {
params: Promise<{ patientId: string }>;
}
async function getPatientWithLogs(patientId: string) {
const supabase = await createClient();
const today = new Date().toISOString().split('T')[0];
const [patientResult, logsResult] = await Promise.all([
supabase
.from('patients')
.select('id, name_given, name_family, name_prefix, birth_date, gender')
.eq('id', patientId)
.single(),
supabase
.from('nursing_logs')
.select('*')
.eq('patient_id', patientId)
.eq('shift_date', today)
.order('timestamp', { ascending: false }),
]);
if (patientResult.error || !patientResult.data) {
return null;
}
return {
patient: patientResult.data,
logs: (logsResult.data || []) as NursingLog[],
date: today,
};
}
function formatPatientName(
nameGiven: string[],
nameFamily: string,
namePrefix?: string | null
): 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;
}
export default async function DagregistratiePage({ params }: PageProps) {
const { patientId } = await params;
const data = await getPatientWithLogs(patientId);
if (!data) {
notFound();
}
const { patient, logs, date } = data;
const patientName = formatPatientName(
patient.name_given,
patient.name_family,
patient.name_prefix
);
const age = calculateAge(patient.birth_date);
// Format date for display
const displayDate = new Date(date).toLocaleDateString('nl-NL', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
});
return (
<div className="min-h-screen bg-slate-50">
{/* Header */}
<div className="bg-white border-b border-slate-200">
<div className="max-w-4xl mx-auto px-4 py-4">
<div className="flex items-center gap-4 mb-4">
<Link
href={`/epd/patients/${patientId}`}
className="flex items-center gap-2 text-sm text-slate-600 hover:text-teal-600 transition-colors"
>
<ArrowLeft className="h-4 w-4" />
Terug naar patiënt
</Link>
</div>
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-teal-100 rounded-full flex items-center justify-center">
<ClipboardList className="h-6 w-6 text-teal-600" />
</div>
<div>
<h1 className="text-xl font-semibold text-slate-900">
Dagregistratie
</h1>
<p className="text-sm text-slate-600">
{patientName} ({age} jaar) {displayDate}
</p>
</div>
</div>
</div>
</div>
{/* Content */}
<div className="max-w-4xl mx-auto px-4 py-6">
<LogList
patientId={patientId}
initialLogs={logs}
date={date}
/>
</div>
</div>
);
}