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:
181
app/api/overdracht/[patientId]/route.ts
Normal file
181
app/api/overdracht/[patientId]/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
329
app/api/overdracht/generate/route.ts
Normal file
329
app/api/overdracht/generate/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
206
app/api/overdracht/patients/route.ts
Normal file
206
app/api/overdracht/patients/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
231
app/epd/dagregistratie/[patientId]/components/log-form.tsx
Normal file
231
app/epd/dagregistratie/[patientId]/components/log-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
397
app/epd/dagregistratie/[patientId]/components/log-list.tsx
Normal file
397
app/epd/dagregistratie/[patientId]/components/log-list.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
134
app/epd/dagregistratie/[patientId]/page.tsx
Normal file
134
app/epd/dagregistratie/[patientId]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
# Mission Control - Bouwplan Overdracht Dashboard
|
# Mission Control - Bouwplan Overdracht Dashboard
|
||||||
|
|
||||||
**Projectnaam:** Verpleegkundige Overdracht Dashboard
|
**Projectnaam:** Verpleegkundige Overdracht Dashboard
|
||||||
**Versie:** v1.0
|
**Versie:** v1.2
|
||||||
**Datum:** 05-12-2024
|
**Datum:** 06-12-2024
|
||||||
**Auteur:** Colin Lit
|
**Auteur:** Colin Lit
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -66,10 +66,10 @@
|
|||||||
|
|
||||||
| Epic ID | Titel | Doel | Status | Stories | Complexiteit |
|
| Epic ID | Titel | Doel | Status | Stories | Complexiteit |
|
||||||
|---------|-------|------|--------|---------|--------------|
|
|---------|-------|------|--------|---------|--------------|
|
||||||
| E0 | Database Setup | nursing_logs tabel + RLS | ⏳ To Do | 2 | Laag |
|
| E0 | Database Setup | nursing_logs tabel + RLS | ✅ Done | 2 | Laag |
|
||||||
| E1 | API Nursing Logs | CRUD endpoints voor dagnotities | ⏳ To Do | 2 | Laag |
|
| E1 | API Nursing Logs | CRUD endpoints voor dagnotities | ✅ Done | 2 | Laag |
|
||||||
| E2 | API Overdracht | Endpoints voor overdracht data + AI | ⏳ To Do | 3 | Middel |
|
| E2 | API Overdracht | Endpoints voor overdracht data + AI | ✅ Done | 3 | Middel |
|
||||||
| E3 | Dagregistratie UI | Quick entry module | ⏳ To Do | 3 | Middel |
|
| E3 | Dagregistratie UI | Quick entry module | ✅ Done | 3 | Middel |
|
||||||
| E4 | Overdracht Overzicht | Patiënten grid | ⏳ To Do | 2 | Middel |
|
| E4 | Overdracht Overzicht | Patiënten grid | ⏳ To Do | 2 | Middel |
|
||||||
| E5 | Overdracht Detail | Info blokken + AI samenvatting | ⏳ To Do | 4 | Middel |
|
| E5 | Overdracht Detail | Info blokken + AI samenvatting | ⏳ To Do | 4 | Middel |
|
||||||
| E6 | Integratie & Polish | Sidebar, navigatie, testing | ⏳ To Do | 3 | Laag |
|
| E6 | Integratie & Polish | Sidebar, navigatie, testing | ⏳ To Do | 3 | Laag |
|
||||||
@@ -85,8 +85,8 @@
|
|||||||
|
|
||||||
| Story ID | Beschrijving | Acceptatiecriteria | Status | Afh. | SP |
|
| Story ID | Beschrijving | Acceptatiecriteria | Status | Afh. | SP |
|
||||||
|----------|--------------|---------------------|--------|------|----|
|
|----------|--------------|---------------------|--------|------|----|
|
||||||
| E0.S1 | nursing_logs tabel aanmaken | Tabel bestaat met alle kolommen uit TO, indexes aanwezig | ⏳ | - | 2 |
|
| E0.S1 | nursing_logs tabel aanmaken | Tabel bestaat met alle kolommen uit TO, indexes aanwezig | ✅ | - | 2 |
|
||||||
| E0.S2 | RLS policies implementeren | SELECT/INSERT/UPDATE/DELETE policies actief, alleen eigen logs muteerbaar | ⏳ | E0.S1 | 2 |
|
| E0.S2 | RLS policies implementeren | SELECT/INSERT/UPDATE/DELETE policies actief, alleen eigen logs muteerbaar | ✅ | E0.S1 | 2 |
|
||||||
|
|
||||||
**Technical Notes:**
|
**Technical Notes:**
|
||||||
- Migratie via `npx supabase migration new create_nursing_logs`
|
- Migratie via `npx supabase migration new create_nursing_logs`
|
||||||
@@ -100,8 +100,8 @@
|
|||||||
|
|
||||||
| Story ID | Beschrijving | Acceptatiecriteria | Status | Afh. | SP |
|
| Story ID | Beschrijving | Acceptatiecriteria | Status | Afh. | SP |
|
||||||
|----------|--------------|---------------------|--------|------|----|
|
|----------|--------------|---------------------|--------|------|----|
|
||||||
| E1.S1 | GET/POST /api/nursing-logs | Ophalen per patient+date, aanmaken met Zod validatie | ⏳ | E0.S2 | 3 |
|
| E1.S1 | GET/POST /api/nursing-logs | Ophalen per patient+date, aanmaken met Zod validatie | ✅ | E0.S2 | 3 |
|
||||||
| E1.S2 | PATCH/DELETE /api/nursing-logs/[id] | Update eigen logs, soft delete | ⏳ | E1.S1 | 2 |
|
| E1.S2 | PATCH/DELETE /api/nursing-logs/[id] | Update eigen logs, hard delete | ✅ | E1.S1 | 2 |
|
||||||
|
|
||||||
**Technical Notes:**
|
**Technical Notes:**
|
||||||
- Pattern volgen van `app/api/reports/route.ts`
|
- Pattern volgen van `app/api/reports/route.ts`
|
||||||
@@ -115,9 +115,9 @@
|
|||||||
|
|
||||||
| Story ID | Beschrijving | Acceptatiecriteria | Status | Afh. | SP |
|
| Story ID | Beschrijving | Acceptatiecriteria | Status | Afh. | SP |
|
||||||
|----------|--------------|---------------------|--------|------|----|
|
|----------|--------------|---------------------|--------|------|----|
|
||||||
| E2.S1 | GET /api/overdracht/patients | Retourneert patiënten met encounters vandaag + alert counts | ⏳ | E0.S2 | 3 |
|
| E2.S1 | GET /api/overdracht/patients | Retourneert patiënten met encounters vandaag + alert counts | ✅ | E0.S2 | 3 |
|
||||||
| E2.S2 | GET /api/overdracht/[patientId] | Retourneert patient + vitals + reports + logs + risks + conditions | ⏳ | E1.S1 | 5 |
|
| E2.S2 | GET /api/overdracht/[patientId] | Retourneert patient + vitals + reports + logs + risks + conditions | ✅ | E1.S1 | 5 |
|
||||||
| E2.S3 | POST /api/overdracht/generate | AI samenvatting met bronverwijzingen, logging naar ai_events | ⏳ | E2.S2 | 5 |
|
| E2.S3 | POST /api/overdracht/generate | AI samenvatting met bronverwijzingen, logging naar ai_events | ✅ | E2.S2 | 5 |
|
||||||
|
|
||||||
**Technical Notes:**
|
**Technical Notes:**
|
||||||
- E2.S2: Parallel queries via Promise.all()
|
- E2.S2: Parallel queries via Promise.all()
|
||||||
@@ -131,9 +131,9 @@
|
|||||||
|
|
||||||
| Story ID | Beschrijving | Acceptatiecriteria | Status | Afh. | SP |
|
| Story ID | Beschrijving | Acceptatiecriteria | Status | Afh. | SP |
|
||||||
|----------|--------------|---------------------|--------|------|----|
|
|----------|--------------|---------------------|--------|------|----|
|
||||||
| E3.S1 | Dagregistratie page | Route `/epd/dagregistratie/[patientId]`, lijst van notities vandaag | ⏳ | E1.S2 | 3 |
|
| E3.S1 | Dagregistratie page | Route `/epd/dagregistratie/[patientId]`, lijst van notities vandaag | ✅ | E1.S2 | 3 |
|
||||||
| E3.S2 | Quick entry form | Categorie dropdown, tijd, tekst (max 500), overdracht checkbox | ⏳ | E3.S1 | 5 |
|
| E3.S2 | Quick entry form | Categorie dropdown, tijd, tekst (max 500), overdracht checkbox | ✅ | E3.S1 | 5 |
|
||||||
| E3.S3 | Edit/Delete functionality | Inline edit, confirm delete dialog | ⏳ | E3.S2 | 3 |
|
| E3.S3 | Edit/Delete functionality | Inline edit, confirm delete dialog | ✅ | E3.S2 | 3 |
|
||||||
|
|
||||||
**Technical Notes:**
|
**Technical Notes:**
|
||||||
- Icons per categorie: Pill (medicatie), Utensils (adl), User (gedrag), AlertTriangle (incident), FileText (observatie)
|
- Icons per categorie: Pill (medicatie), Utensils (adl), User (gedrag), AlertTriangle (incident), FileText (observatie)
|
||||||
@@ -312,3 +312,5 @@
|
|||||||
| Versie | Datum | Auteur | Wijziging |
|
| Versie | Datum | Auteur | Wijziging |
|
||||||
|--------|-------|--------|-----------|
|
|--------|-------|--------|-----------|
|
||||||
| v1.0 | 05-12-2024 | Colin | Initieel bouwplan gebaseerd op PRD, FO en TO |
|
| v1.0 | 05-12-2024 | Colin | Initieel bouwplan gebaseerd op PRD, FO en TO |
|
||||||
|
| v1.1 | 06-12-2024 | Claude | E0 + E1 afgerond: database setup + API nursing logs |
|
||||||
|
| v1.2 | 06-12-2024 | Claude | E2 + E3 afgerond: API overdracht + dagregistratie UI |
|
||||||
|
|||||||
225
lib/ai/overdracht-prompt.ts
Normal file
225
lib/ai/overdracht-prompt.ts
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
/**
|
||||||
|
* Overdracht AI Prompt Templates
|
||||||
|
*
|
||||||
|
* System and user prompts for generating nursing handover summaries
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
VitalSign,
|
||||||
|
Report,
|
||||||
|
RiskAssessment,
|
||||||
|
Condition,
|
||||||
|
} from '@/lib/types/overdracht';
|
||||||
|
import type { NursingLog } from '@/lib/types/nursing-log';
|
||||||
|
|
||||||
|
export interface OverdrachtContext {
|
||||||
|
patientId: string;
|
||||||
|
patientName: string;
|
||||||
|
age: number;
|
||||||
|
gender: string;
|
||||||
|
conditions: Condition[];
|
||||||
|
vitals: VitalSign[];
|
||||||
|
reports: Report[];
|
||||||
|
nursingLogs: NursingLog[];
|
||||||
|
risks: RiskAssessment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OVERDRACHT_SYSTEM_PROMPT = `Je bent een ervaren verpleegkundige die overdrachten maakt in een GGZ-instelling.
|
||||||
|
|
||||||
|
Je taak: Maak een beknopte, relevante overdracht voor de opvolgende dienst.
|
||||||
|
|
||||||
|
## Outputformaat (JSON)
|
||||||
|
{
|
||||||
|
"samenvatting": "1-2 zinnen over de patiënt en wat er speelt",
|
||||||
|
"aandachtspunten": [
|
||||||
|
{
|
||||||
|
"tekst": "Beschrijving van het aandachtspunt",
|
||||||
|
"urgent": true/false,
|
||||||
|
"bron": {
|
||||||
|
"type": "observatie|rapportage|dagnotitie|risico",
|
||||||
|
"id": "source-id",
|
||||||
|
"datum": "DD-MM-YYYY HH:mm",
|
||||||
|
"label": "Korte beschrijving bron"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"actiepunten": [
|
||||||
|
"Concrete actie voor opvolgende dienst"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
## Regels
|
||||||
|
1. Taal: Nederlands, zakelijk, beknopt
|
||||||
|
2. Focus op: veranderingen, afwijkingen, aandachtspunten
|
||||||
|
3. Elke aandachtspunt MOET een bronverwijzing hebben
|
||||||
|
4. Maximum 5 aandachtspunten, 3 actiepunten
|
||||||
|
5. Markeer urgent=true voor:
|
||||||
|
- Medicatie-weigering
|
||||||
|
- Incidenten
|
||||||
|
- Sterk afwijkende vitals (HH, LL, H, L interpretatie)
|
||||||
|
- Hoog-risico situaties (risico_level: hoog of zeer_hoog)
|
||||||
|
6. Als er weinig data is, geef een korte samenvatting en meld dat er weinig bijzonderheden zijn
|
||||||
|
7. Gebruik ALLEEN informatie uit de aangeleverde bronnen, verzin NIETS
|
||||||
|
|
||||||
|
## Bronverwijzing formaat
|
||||||
|
- type "observatie" voor vitale functies (observations tabel)
|
||||||
|
- type "rapportage" voor rapporten (reports tabel)
|
||||||
|
- type "dagnotitie" voor nursing logs (nursing_logs tabel)
|
||||||
|
- type "risico" voor risico assessments (risk_assessments tabel)
|
||||||
|
|
||||||
|
Geef je antwoord als PURE JSON, zonder markdown code blocks.`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format timestamp to Dutch date format
|
||||||
|
*/
|
||||||
|
function formatDateTime(timestamp: string): string {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
return date.toLocaleString('nl-NL', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get interpretation label for vital signs
|
||||||
|
*/
|
||||||
|
function getInterpretationLabel(code: string | null | undefined): string {
|
||||||
|
if (!code) return 'normaal';
|
||||||
|
switch (code) {
|
||||||
|
case 'HH':
|
||||||
|
return 'kritiek hoog';
|
||||||
|
case 'H':
|
||||||
|
return 'hoog';
|
||||||
|
case 'LL':
|
||||||
|
return 'kritiek laag';
|
||||||
|
case 'L':
|
||||||
|
return 'laag';
|
||||||
|
case 'N':
|
||||||
|
return 'normaal';
|
||||||
|
default:
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truncate text to max length
|
||||||
|
*/
|
||||||
|
function truncate(text: string, maxLength: number): string {
|
||||||
|
if (text.length <= maxLength) return text;
|
||||||
|
return text.slice(0, maxLength - 3) + '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the user prompt with patient context
|
||||||
|
*/
|
||||||
|
export function buildOverdrachtUserPrompt(context: OverdrachtContext): string {
|
||||||
|
const lines: string[] = [
|
||||||
|
`PATIENT: ${context.patientName}, ${context.age} jaar, ${context.gender}`,
|
||||||
|
'',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Diagnoses
|
||||||
|
lines.push('DIAGNOSES:');
|
||||||
|
if (context.conditions.length > 0) {
|
||||||
|
for (const c of context.conditions) {
|
||||||
|
lines.push(`- ${c.code_display} (source: conditions/${c.id})`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lines.push('- Geen actieve diagnoses geregistreerd');
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
// Vitals
|
||||||
|
lines.push('VITALE FUNCTIES (vandaag):');
|
||||||
|
if (context.vitals.length > 0) {
|
||||||
|
for (const v of context.vitals) {
|
||||||
|
const value = v.value_quantity_value ?? 'onbekend';
|
||||||
|
const unit = v.value_quantity_unit ?? '';
|
||||||
|
const interpretation = getInterpretationLabel(v.interpretation_code);
|
||||||
|
lines.push(
|
||||||
|
`- ${v.code_display}: ${value} ${unit} [${interpretation}] ` +
|
||||||
|
`(source: observations/${v.id}, ${formatDateTime(v.effective_datetime)})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lines.push('- Geen metingen vandaag');
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
// Reports
|
||||||
|
lines.push('RAPPORTAGES (laatste 24u):');
|
||||||
|
if (context.reports.length > 0) {
|
||||||
|
for (const r of context.reports) {
|
||||||
|
lines.push(
|
||||||
|
`- [${formatDateTime(r.created_at)}] ${r.type}: "${truncate(r.content, 200)}" ` +
|
||||||
|
`(source: reports/${r.id})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lines.push('- Geen rapportages in de laatste 24 uur');
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
// Nursing logs
|
||||||
|
lines.push('DAGREGISTRATIES (vandaag):');
|
||||||
|
if (context.nursingLogs.length > 0) {
|
||||||
|
for (const l of context.nursingLogs) {
|
||||||
|
const handoverMark = l.include_in_handover ? '[OVERDRACHT]' : '';
|
||||||
|
lines.push(
|
||||||
|
`- [${formatDateTime(l.timestamp)}] [${l.category.toUpperCase()}] ${handoverMark} ${l.content} ` +
|
||||||
|
`(source: nursing_logs/${l.id})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lines.push('- Geen dagregistraties vandaag');
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
// Risks
|
||||||
|
lines.push('RISICOS:');
|
||||||
|
if (context.risks.length > 0) {
|
||||||
|
for (const r of context.risks) {
|
||||||
|
const rationale = r.rationale ? `: "${truncate(r.rationale, 150)}"` : '';
|
||||||
|
lines.push(
|
||||||
|
`- [${r.risk_level.toUpperCase()}] ${r.risk_type}${rationale} ` +
|
||||||
|
`(source: risk_assessments/${r.id})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lines.push('- Geen risico assessments geregistreerd');
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate age from birth date
|
||||||
|
*/
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format patient name
|
||||||
|
*/
|
||||||
|
export function formatPatientName(
|
||||||
|
nameGiven: string[],
|
||||||
|
nameFamily: string,
|
||||||
|
namePrefix?: string
|
||||||
|
): string {
|
||||||
|
const given = nameGiven.join(' ');
|
||||||
|
if (namePrefix) {
|
||||||
|
return `${given} ${namePrefix} ${nameFamily}`;
|
||||||
|
}
|
||||||
|
return `${given} ${nameFamily}`;
|
||||||
|
}
|
||||||
103
lib/types/overdracht.ts
Normal file
103
lib/types/overdracht.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import type { NursingLog } from './nursing-log';
|
||||||
|
|
||||||
|
// Patient overview for list view
|
||||||
|
export interface PatientOverzicht {
|
||||||
|
id: string;
|
||||||
|
name_given: string[];
|
||||||
|
name_family: string;
|
||||||
|
birth_date: string;
|
||||||
|
gender: string;
|
||||||
|
alerts: {
|
||||||
|
high_risk_count: number;
|
||||||
|
abnormal_vitals_count: number;
|
||||||
|
marked_logs_count: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PatientOverzichtResponse {
|
||||||
|
patients: PatientOverzicht[];
|
||||||
|
total: number;
|
||||||
|
date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patient detail for detail view
|
||||||
|
export interface PatientDetail {
|
||||||
|
patient: {
|
||||||
|
id: string;
|
||||||
|
name_given: string[];
|
||||||
|
name_family: string;
|
||||||
|
name_prefix?: string;
|
||||||
|
birth_date: string;
|
||||||
|
gender: string;
|
||||||
|
};
|
||||||
|
vitals: VitalSign[];
|
||||||
|
reports: Report[];
|
||||||
|
nursingLogs: NursingLog[];
|
||||||
|
risks: RiskAssessment[];
|
||||||
|
conditions: Condition[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vital sign with interpretation
|
||||||
|
export interface VitalSign {
|
||||||
|
id: string;
|
||||||
|
code_display: string;
|
||||||
|
value_quantity_value: number | null;
|
||||||
|
value_quantity_unit: string | null;
|
||||||
|
interpretation_code?: string | null; // 'H' | 'L' | 'N'
|
||||||
|
effective_datetime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report (rapportage)
|
||||||
|
export interface Report {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
content: string;
|
||||||
|
created_at: string;
|
||||||
|
created_by: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Risk assessment
|
||||||
|
export interface RiskAssessment {
|
||||||
|
id: string;
|
||||||
|
risk_type: string;
|
||||||
|
risk_level: string;
|
||||||
|
rationale?: string | null;
|
||||||
|
created_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Condition (diagnose)
|
||||||
|
export interface Condition {
|
||||||
|
id: string;
|
||||||
|
code_display: string;
|
||||||
|
clinical_status: string;
|
||||||
|
onset_datetime?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI Summary types
|
||||||
|
export interface AISamenvatting {
|
||||||
|
samenvatting: string;
|
||||||
|
aandachtspunten: Aandachtspunt[];
|
||||||
|
actiepunten: string[];
|
||||||
|
generatedAt: string;
|
||||||
|
durationMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Aandachtspunt {
|
||||||
|
tekst: string;
|
||||||
|
urgent: boolean;
|
||||||
|
bron: {
|
||||||
|
type: 'observatie' | 'rapportage' | 'dagnotitie' | 'risico';
|
||||||
|
id: string;
|
||||||
|
datum: string;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zod schema for generate request
|
||||||
|
export const GenerateOverdrachtSchema = z.object({
|
||||||
|
patientId: z.string().uuid('Patient ID moet een geldige UUID zijn'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type GenerateOverdrachtInput = z.infer<typeof GenerateOverdrachtSchema>;
|
||||||
Reference in New Issue
Block a user