feat(verpleegrapportage): Nieuwe module + opruiming codebase

Verpleegrapportage module:
- Nieuwe /epd/verpleegrapportage met patiëntenoverzicht
- Rapportage invoer workspace met timeline view
- Overdracht overzicht met AI-samenvatting
- API endpoints voor verpleegrapportage data

Opruiming:
- Oude /epd/overdracht en /epd/dagregistratie verwijderd (vervangen)
- Oude /api/nursing-logs verwijderd (geconsolideerd naar reports)
- Verouderde design docs en reports verwijderd
- Fonts verplaatst van docs/ naar public/fonts/

Bugfixes:
- Risk-manager: fix constraint violation (db values vs display labels)
- Overdracht API: filter op rapportages i.p.v. encounters

Database:
- Migratie voor consolidatie nursing_logs naar reports tabel

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
colinislit
2025-12-08 11:19:48 +01:00
parent d9340ba296
commit 75c7e284b9
222 changed files with 5473 additions and 7494 deletions

View File

@@ -1,182 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { createClient } from '@/lib/auth/server';
import {
UpdateNursingLogSchema,
calculateShiftDate,
} from '@/lib/types/nursing-log';
interface RouteParams {
params: Promise<{ id: string }>;
}
export async function PATCH(request: NextRequest, { params }: RouteParams) {
try {
const { id } = await params;
if (!z.string().uuid().safeParse(id).success) {
return NextResponse.json(
{ error: 'id moet een geldige UUID zijn' },
{ status: 400 }
);
}
const body = await request.json();
const result = UpdateNursingLogSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{
error: 'Validatiefout',
details: result.error.issues.map((issue) => ({
field: issue.path.join('.'),
message: issue.message,
})),
},
{ status: 400 }
);
}
const supabase = await createClient();
const { data: authData } = await supabase.auth.getUser();
if (!authData?.user) {
return NextResponse.json({ error: 'Niet geautoriseerd' }, { status: 401 });
}
// Check if log exists and belongs to current user
const { data: existingLog, error: fetchError } = await supabase
.from('nursing_logs')
.select('id, created_by')
.eq('id', id)
.single();
if (fetchError || !existingLog) {
return NextResponse.json(
{ error: 'Dagnotitie niet gevonden' },
{ status: 404 }
);
}
if (existingLog.created_by !== authData.user.id) {
return NextResponse.json(
{ error: 'Je kunt alleen je eigen notities bewerken' },
{ status: 403 }
);
}
// Build update object
const updateData: Record<string, unknown> = {};
const { category, content, timestamp, include_in_handover } = result.data;
if (category !== undefined) updateData.category = category;
if (content !== undefined) updateData.content = content;
if (include_in_handover !== undefined)
updateData.include_in_handover = include_in_handover;
// If timestamp changes, recalculate shift_date
if (timestamp !== undefined) {
updateData.timestamp = timestamp;
updateData.shift_date = calculateShiftDate(timestamp);
}
if (Object.keys(updateData).length === 0) {
return NextResponse.json(
{ error: 'Geen velden om te updaten' },
{ status: 400 }
);
}
const { data, error } = await supabase
.from('nursing_logs')
.update(updateData)
.eq('id', id)
.select('*')
.single();
if (error) {
console.error('Error updating nursing log:', error);
return NextResponse.json(
{ error: 'Bijwerken mislukt', details: error.message },
{ status: 500 }
);
}
return NextResponse.json(data);
} catch (error) {
console.error('Unexpected error in PATCH /api/nursing-logs/[id]:', error);
if (error instanceof SyntaxError) {
return NextResponse.json(
{ error: 'Ongeldige JSON in request body' },
{ status: 400 }
);
}
return NextResponse.json(
{ error: 'Onverwachte serverfout' },
{ status: 500 }
);
}
}
export async function DELETE(request: NextRequest, { params }: RouteParams) {
try {
const { id } = await params;
if (!z.string().uuid().safeParse(id).success) {
return NextResponse.json(
{ error: 'id moet een geldige UUID zijn' },
{ status: 400 }
);
}
const supabase = await createClient();
const { data: authData } = await supabase.auth.getUser();
if (!authData?.user) {
return NextResponse.json({ error: 'Niet geautoriseerd' }, { status: 401 });
}
// Check if log exists and belongs to current user
const { data: existingLog, error: fetchError } = await supabase
.from('nursing_logs')
.select('id, created_by')
.eq('id', id)
.single();
if (fetchError || !existingLog) {
return NextResponse.json(
{ error: 'Dagnotitie niet gevonden' },
{ status: 404 }
);
}
if (existingLog.created_by !== authData.user.id) {
return NextResponse.json(
{ error: 'Je kunt alleen je eigen notities verwijderen' },
{ status: 403 }
);
}
// Hard delete (RLS policy already ensures user can only delete own logs)
const { error } = await supabase
.from('nursing_logs')
.delete()
.eq('id', id);
if (error) {
console.error('Error deleting nursing log:', error);
return NextResponse.json(
{ error: 'Verwijderen mislukt', details: error.message },
{ status: 500 }
);
}
return new NextResponse(null, { status: 204 });
} catch (error) {
console.error('Unexpected error in DELETE /api/nursing-logs/[id]:', error);
return NextResponse.json(
{ error: 'Onverwachte serverfout' },
{ status: 500 }
);
}
}

View File

@@ -1,146 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { createClient } from '@/lib/auth/server';
import {
CreateNursingLogSchema,
calculateShiftDate,
type NursingLogListResponse,
} from '@/lib/types/nursing-log';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const patientId = searchParams.get('patientId');
const date = searchParams.get('date'); // Optional: YYYY-MM-DD format
if (!patientId) {
return NextResponse.json(
{ error: 'patientId query parameter is verplicht' },
{ status: 400 }
);
}
if (!z.string().uuid().safeParse(patientId).success) {
return NextResponse.json(
{ error: 'patientId moet een geldige UUID zijn' },
{ status: 400 }
);
}
// Validate date format if provided
if (date && !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
return NextResponse.json(
{ error: 'date moet in YYYY-MM-DD formaat zijn' },
{ status: 400 }
);
}
const supabase = await createClient();
let query = supabase
.from('nursing_logs')
.select('*')
.eq('patient_id', patientId)
.order('timestamp', { ascending: false });
// Filter by shift_date if date is provided
if (date) {
query = query.eq('shift_date', date);
}
const { data, error } = await query;
if (error) {
console.error('Error fetching nursing logs:', error);
return NextResponse.json(
{ error: 'Fout bij ophalen dagnotities', details: error.message },
{ status: 500 }
);
}
const response: NursingLogListResponse = {
logs: data ?? [],
total: data?.length ?? 0,
};
return NextResponse.json(response);
} catch (error) {
console.error('Unexpected error in GET /api/nursing-logs:', error);
return NextResponse.json(
{ error: 'Onverwachte serverfout' },
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const result = CreateNursingLogSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{
error: 'Validatiefout',
details: result.error.issues.map((issue) => ({
field: issue.path.join('.'),
message: issue.message,
})),
},
{ status: 400 }
);
}
const supabase = await createClient();
const { data: authData } = await supabase.auth.getUser();
if (!authData?.user) {
return NextResponse.json({ error: 'Niet geautoriseerd' }, { status: 401 });
}
const { patient_id, category, content, timestamp, include_in_handover } =
result.data;
// Use provided timestamp or current time
const logTimestamp = timestamp || new Date().toISOString();
// Calculate shift_date from timestamp
const shiftDate = calculateShiftDate(logTimestamp);
const { data, error } = await supabase
.from('nursing_logs')
.insert({
patient_id,
category,
content,
timestamp: logTimestamp,
shift_date: shiftDate,
include_in_handover: include_in_handover ?? false,
created_by: authData.user.id,
})
.select('*')
.single();
if (error) {
console.error('Error creating nursing log:', error);
return NextResponse.json(
{ error: 'Opslaan mislukt', details: error.message },
{ status: 500 }
);
}
return NextResponse.json(data, { status: 201 });
} catch (error) {
console.error('Unexpected error in POST /api/nursing-logs:', error);
if (error instanceof SyntaxError) {
return NextResponse.json(
{ error: 'Ongeldige JSON in request body' },
{ status: 400 }
);
}
return NextResponse.json(
{ error: 'Onverwachte serverfout' },
{ status: 500 }
);
}
}

View File

@@ -8,7 +8,7 @@ import type {
RiskAssessment,
Condition,
} from '@/lib/types/overdracht';
import type { NursingLog } from '@/lib/types/nursing-log';
import { VERPLEEG_REPORT_TYPES } from '@/lib/types/report';
interface RouteParams {
params: Promise<{ patientId: string }>;
@@ -37,7 +37,6 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
patientResult,
vitalsResult,
reportsResult,
logsResult,
risksResult,
conditionsResult,
] = await Promise.all([
@@ -57,31 +56,24 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
.gte('effective_datetime', todayStart)
.order('effective_datetime', { ascending: false }),
// 3. Reports last 24h
// 3. Reports last 24h - includes verpleegkundig (was nursing_logs) plus observatie, incident, etc
supabase
.from('reports')
.select('id, type, content, created_at, created_by')
.select('id, type, content, created_at, created_by, structured_data, include_in_handover, shift_date')
.eq('patient_id', patientId)
.in('type', [...VERPLEEG_REPORT_TYPES])
.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
// 4. 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
// 5. Active conditions
supabase
.from('conditions')
.select('id, code_display, clinical_status, onset_datetime')
@@ -104,9 +96,6 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
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);
}
@@ -124,18 +113,18 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
effective_datetime: v.effective_datetime,
}));
// Map reports
// Map reports (now includes verpleegkundig type)
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,
structured_data: r.structured_data,
include_in_handover: r.include_in_handover,
shift_date: r.shift_date,
}));
// 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,
@@ -165,7 +154,6 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
},
vitals,
reports,
nursingLogs,
risks,
conditions,
};

View File

@@ -20,14 +20,14 @@ import {
type AISamenvatting,
type Aandachtspunt,
} from '@/lib/types/overdracht';
import type { NursingLog } from '@/lib/types/nursing-log';
import { VERPLEEG_REPORT_TYPES } from '@/lib/types/report';
// 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']),
type: z.enum(['observatie', 'rapportage', 'verpleegkundig', 'risico']),
id: z.string(),
datum: z.string(),
label: z.string(),
@@ -40,23 +40,33 @@ const AIResponseSchema = z.object({
actiepunten: z.array(z.string()).max(3),
});
type PeriodValue = '1d' | '3d' | '7d' | '14d';
/**
* Calculate start date based on period
*/
function getPeriodStartDate(period: PeriodValue): string {
const days = { '1d': 1, '3d': 3, '7d': 7, '14d': 14 }[period] || 1;
const startDate = new Date();
startDate.setDate(startDate.getDate() - (days - 1));
return startDate.toISOString().split('T')[0] + 'T00:00:00.000Z';
}
/**
* Load context from database
*/
async function loadOverdrachtContext(
supabase: Awaited<ReturnType<typeof createClient>>,
patientId: string
patientId: string,
period: PeriodValue
): 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();
const periodStart = getPeriodStartDate(period);
// Parallel queries
// Parallel queries - reports now includes verpleegkundig type
const [
patientResult,
vitalsResult,
reportsResult,
logsResult,
risksResult,
conditionsResult,
] = await Promise.all([
@@ -70,21 +80,17 @@ async function loadOverdrachtContext(
.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)
.gte('effective_datetime', periodStart)
.order('effective_datetime', { ascending: false }),
// Reports now includes verpleegkundig type (was nursing_logs)
supabase
.from('reports')
.select('id, type, content, created_at, created_by')
.select('id, type, content, created_at, created_by, structured_data, include_in_handover, shift_date')
.eq('patient_id', patientId)
.gte('created_at', last24h)
.in('type', [...VERPLEEG_REPORT_TYPES])
.gte('created_at', periodStart)
.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)')
@@ -131,8 +137,10 @@ async function loadOverdrachtContext(
content: r.content,
created_at: r.created_at,
created_by: r.created_by,
structured_data: r.structured_data,
include_in_handover: r.include_in_handover,
shift_date: r.shift_date,
})),
nursingLogs: (logsResult.data || []) as NursingLog[],
risks: (risksResult.data || []).map((r) => ({
id: r.id,
risk_type: r.risk_type,
@@ -218,13 +226,17 @@ async function logAIEvent(
durationMs: number
) {
try {
// Count verpleegkundige reports separately
const verpleegkundigCount = context.reports.filter(r => r.type === 'verpleegkundig').length;
const otherReportsCount = context.reports.filter(r => r.type !== 'verpleegkundig').length;
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,
reportCount: otherReportsCount,
verpleegkundigCount,
riskCount: context.risks.length,
conditionCount: context.conditions.length,
},
@@ -265,7 +277,7 @@ export async function POST(request: NextRequest) {
);
}
const { patientId } = result.data;
const { patientId, period } = result.data;
const supabase = await createClient();
// Check auth
@@ -274,8 +286,8 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Niet geautoriseerd' }, { status: 401 });
}
// Load context
const context = await loadOverdrachtContext(supabase, patientId);
// Load context with period filter
const context = await loadOverdrachtContext(supabase, patientId, period);
// Call Claude API
const aiResult = await callClaudeAPI(context);

View File

@@ -105,13 +105,15 @@ export async function GET(request: NextRequest) {
.lte('effective_datetime', dayEnd)
.in('interpretation_code', ['H', 'L', 'HH', 'LL']),
// Marked nursing logs for handover
// Marked reports (type=verpleegkundig) for handover
supabase
.from('nursing_logs')
.from('reports')
.select('id, patient_id')
.in('patient_id', patientIds)
.eq('type', 'verpleegkundig')
.eq('shift_date', targetDate)
.eq('include_in_handover', true),
.eq('include_in_handover', true)
.is('deleted_at', null),
]);
// Count alerts per patient

View File

@@ -1,12 +1,23 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { createClient } from '@/lib/auth/server';
import { CreateReportSchema, type ReportListResponse } from '@/lib/types/report';
import {
CreateReportSchema,
CreateVerpleegkundigSchema,
calculateShiftDate,
type ReportListResponse,
VERPLEEG_REPORT_TYPES,
} from '@/lib/types/report';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const patientId = searchParams.get('patientId');
const type = searchParams.get('type'); // Optional: filter by type
const types = searchParams.get('types'); // Optional: comma-separated list of types
const startDate = searchParams.get('startDate'); // Optional: YYYY-MM-DD
const endDate = searchParams.get('endDate'); // Optional: YYYY-MM-DD
const includeInHandover = searchParams.get('includeInHandover'); // Optional: 'true'
if (!patientId) {
return NextResponse.json(
@@ -22,14 +33,56 @@ export async function GET(request: NextRequest) {
);
}
// Validate date formats
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
if (startDate && !dateRegex.test(startDate)) {
return NextResponse.json(
{ error: 'startDate moet in YYYY-MM-DD formaat zijn' },
{ status: 400 }
);
}
if (endDate && !dateRegex.test(endDate)) {
return NextResponse.json(
{ error: 'endDate moet in YYYY-MM-DD formaat zijn' },
{ status: 400 }
);
}
const supabase = await createClient();
const { data, error } = await supabase
let query = supabase
.from('reports')
.select('*')
.eq('patient_id', patientId)
.is('deleted_at', null)
.order('created_at', { ascending: false });
// Filter by single type
if (type) {
query = query.eq('type', type);
}
// Filter by multiple types (comma-separated)
if (types) {
const typeList = types.split(',').map((t) => t.trim());
query = query.in('type', typeList);
}
// Filter by date range (for shift_date, used by verpleegkundig)
if (startDate && endDate) {
query = query.gte('shift_date', startDate).lte('shift_date', endDate);
} else if (startDate) {
query = query.gte('shift_date', startDate);
} else if (endDate) {
query = query.lte('shift_date', endDate);
}
// Filter for handover reports only
if (includeInHandover === 'true') {
query = query.eq('include_in_handover', true);
}
const { data, error } = await query;
if (error) {
console.error('Error fetching reports:', error);
return NextResponse.json(
@@ -56,7 +109,14 @@ export async function GET(request: NextRequest) {
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const result = CreateReportSchema.safeParse(body);
// Check if this is a verpleegkundig report
const isVerpleegkundig = body.type === 'verpleegkundig';
// Use appropriate schema
const result = isVerpleegkundig
? CreateVerpleegkundigSchema.safeParse(body)
: CreateReportSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
@@ -75,16 +135,50 @@ export async function POST(request: NextRequest) {
const { data: authData } = await supabase.auth.getUser();
if (!authData?.user) {
return NextResponse.json(
{ error: 'Niet geautoriseerd' },
{ status: 401 }
);
return NextResponse.json({ error: 'Niet geautoriseerd' }, { status: 401 });
}
const { patient_id, type, content, ai_confidence, ai_reasoning, encounter_id, intake_id } = result.data;
const { data, error } = await supabase
.from('reports')
.insert({
// Get practitioner ID for the current user
const { data: practitioner } = await supabase
.from('practitioners')
.select('id')
.eq('user_id', authData.user.id)
.single();
if (isVerpleegkundig) {
// Handle verpleegkundig report
const { patient_id, content, category, include_in_handover } =
result.data as z.infer<typeof CreateVerpleegkundigSchema>;
const now = new Date();
const shiftDate = calculateShiftDate(now);
const { data, error } = await supabase
.from('reports')
.insert({
patient_id,
type: 'verpleegkundig',
content,
structured_data: { category },
include_in_handover: include_in_handover ?? false,
shift_date: shiftDate,
created_by: practitioner?.id ?? null,
})
.select('*')
.single();
if (error) {
console.error('Error creating verpleegkundig report:', error);
return NextResponse.json(
{ error: 'Opslaan mislukt', details: error.message },
{ status: 500 }
);
}
return NextResponse.json(data, { status: 201 });
} else {
// Handle standard report
const {
patient_id,
type,
content,
@@ -92,20 +186,33 @@ export async function POST(request: NextRequest) {
ai_reasoning,
encounter_id,
intake_id,
created_by: authData.user.id,
})
.select('*')
.single();
} = result.data as z.infer<typeof CreateReportSchema>;
if (error) {
console.error('Error creating report:', error);
return NextResponse.json(
{ error: 'Opslaan mislukt', details: error.message },
{ status: 500 }
);
const { data, error } = await supabase
.from('reports')
.insert({
patient_id,
type,
content,
ai_confidence,
ai_reasoning,
encounter_id,
intake_id,
created_by: practitioner?.id ?? null,
})
.select('*')
.single();
if (error) {
console.error('Error creating report:', error);
return NextResponse.json(
{ error: 'Opslaan mislukt', details: error.message },
{ status: 500 }
);
}
return NextResponse.json(data, { status: 201 });
}
return NextResponse.json(data, { status: 201 });
} catch (error) {
console.error('Unexpected error in POST /api/reports:', error);
if (error instanceof SyntaxError) {

View File

@@ -0,0 +1,176 @@
/**
* API Route: GET /api/verpleegrapportage/[patientId]
* Haalt patiënt detail data op voor de verpleegrapportage
* Toont alleen verpleegkundig-relevante rapportages
*/
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/auth/server';
import type { PatientDetail } from '@/lib/types/overdracht';
import { VERPLEEG_REPORT_TYPES } from '@/lib/types/report';
type PeriodValue = '1d' | '3d' | '7d' | '14d';
function getPeriodDays(period: PeriodValue): number {
switch (period) {
case '1d': return 1;
case '3d': return 3;
case '7d': return 7;
case '14d': return 14;
default: return 7;
}
}
function getPeriodDateRange(period: PeriodValue): { startDate: string; endDate: string } {
const today = new Date();
const endDate = today.toISOString().split('T')[0];
const days = getPeriodDays(period);
const startDateTime = new Date(today);
startDateTime.setDate(startDateTime.getDate() - (days - 1));
const startDate = startDateTime.toISOString().split('T')[0];
return { startDate, endDate };
}
async function getPatientDetail(patientId: string, period: PeriodValue): Promise<PatientDetail | null> {
const supabase = await createClient();
// Date calculations based on period
const { startDate, endDate } = getPeriodDateRange(period);
const startDatetime = `${startDate}T00:00:00.000Z`;
const endDatetime = `${endDate}T23:59:59.999Z`;
// Parallel queries for all data
const [
patientResult,
vitalsResult,
reportsResult,
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 in period
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', startDatetime)
.lte('effective_datetime', endDatetime)
.order('effective_datetime', { ascending: false }),
// 3. Reports in period - filter on VERPLEEG_REPORT_TYPES
// This now includes 'verpleegkundig' (was nursing_logs) plus observatie, incident, medicatie, crisis
supabase
.from('reports')
.select('id, type, content, created_at, created_by, structured_data, include_in_handover, shift_date')
.eq('patient_id', patientId)
.in('type', [...VERPLEEG_REPORT_TYPES])
.gte('created_at', startDatetime)
.lte('created_at', endDatetime)
.is('deleted_at', null)
.order('created_at', { ascending: false }),
// 4. 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']),
// 5. Active conditions
supabase
.from('conditions')
.select('id, code_display, clinical_status, onset_datetime')
.eq('patient_id', patientId)
.eq('clinical_status', 'active'),
]);
// Check if patient exists
if (patientResult.error || !patientResult.data) {
return null;
}
// Build response
return {
patient: {
id: patientResult.data.id,
name_given: patientResult.data.name_given,
name_family: patientResult.data.name_family,
name_prefix: patientResult.data.name_prefix || undefined,
birth_date: patientResult.data.birth_date,
gender: patientResult.data.gender,
},
vitals: (vitalsResult.data || []).map((v) => ({
id: v.id,
code_display: v.code_display,
value_quantity_value: v.value_quantity_value,
value_quantity_unit: v.value_quantity_unit,
interpretation_code: v.interpretation_code,
effective_datetime: v.effective_datetime,
})),
reports: (reportsResult.data || []).map((r) => ({
id: r.id,
type: r.type,
content: r.content,
created_at: r.created_at,
created_by: r.created_by,
structured_data: r.structured_data,
include_in_handover: r.include_in_handover,
shift_date: r.shift_date,
})),
risks: (risksResult.data || []).map((r) => ({
id: r.id,
risk_type: r.risk_type,
risk_level: r.risk_level,
rationale: r.rationale,
created_at: r.created_at,
})),
conditions: (conditionsResult.data || []).map((c) => ({
id: c.id,
code_display: c.code_display,
clinical_status: c.clinical_status,
onset_datetime: c.onset_datetime || undefined,
})),
};
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ patientId: string }> }
) {
try {
const { patientId } = await params;
const periode = request.nextUrl.searchParams.get('periode') || '7d';
// Validate period
const validPeriods: PeriodValue[] = ['1d', '3d', '7d', '14d'];
const period: PeriodValue = validPeriods.includes(periode as PeriodValue)
? (periode as PeriodValue)
: '7d';
const data = await getPatientDetail(patientId, period);
if (!data) {
return NextResponse.json(
{ error: 'Patiënt niet gevonden' },
{ status: 404 }
);
}
return NextResponse.json(data);
} catch (error) {
console.error('[API /verpleegrapportage/[patientId]] Error:', error);
return NextResponse.json(
{ error: 'Interne serverfout' },
{ status: 500 }
);
}
}

View File

@@ -11,6 +11,7 @@ import {
X,
ChevronLeft,
ChevronRight,
ChevronDown,
FileText,
HelpCircle,
LayoutDashboard,
@@ -18,15 +19,23 @@ import {
ClipboardList,
Stethoscope,
Calendar,
FileBarChart
FileBarChart,
PenLine
} from 'lucide-react';
interface SubNavigationItem {
id: string;
name: string;
href: string;
}
interface NavigationItem {
id: string;
name: string;
icon: React.ComponentType<{ className?: string }>;
href: string;
badge?: string;
subItems?: SubNavigationItem[];
}
interface EPDSidebarProps {
@@ -39,7 +48,16 @@ interface EPDSidebarProps {
const level1NavigationItems: NavigationItem[] = [
{ id: "dashboard", name: "Dashboard", icon: LayoutDashboard, href: "/epd/dashboard" },
{ id: "clients", name: "Cliënten", icon: Users, href: "/epd/patients" },
{ id: "overdracht", name: "Overdracht", icon: ClipboardList, href: "/epd/overdracht" },
{
id: "verpleegrapportage",
name: "Verpleegrapportage",
icon: ClipboardList,
href: "/epd/verpleegrapportage",
subItems: [
{ id: "rapportage", name: "Rapportage", href: "/epd/verpleegrapportage" },
{ id: "overdracht", name: "Overdracht", href: "/epd/verpleegrapportage/overdracht" },
]
},
{ id: "agenda", name: "Agenda", icon: FileText, href: "/epd/agenda" },
{ id: "reports", name: "Rapportage", icon: Settings, href: "/epd/reports" },
];
@@ -126,6 +144,148 @@ const SidebarItem = memo(function SidebarItem({ item, isActive, isCollapsed, onC
prev.isCollapsed === next.isCollapsed;
});
// Sidebar item with expandable submenu
interface SidebarItemWithSubmenuProps {
item: NavigationItem;
isActive: boolean;
isCollapsed: boolean;
onClick: () => void;
pathname: string | null;
}
const SidebarItemWithSubmenu = memo(function SidebarItemWithSubmenu({
item,
isActive,
isCollapsed,
onClick,
pathname
}: SidebarItemWithSubmenuProps) {
const Icon = item.icon;
// Check if any subitem is active
const isSubItemActive = item.subItems?.some(sub => pathname === sub.href) || false;
const isParentOrChildActive = isActive || isSubItemActive;
// Auto-expand when a child is active
const [isExpanded, setIsExpanded] = useState(isSubItemActive);
// Update expanded state when route changes
useEffect(() => {
if (isSubItemActive) {
setIsExpanded(true);
}
}, [isSubItemActive]);
const handleToggle = (e: React.MouseEvent) => {
e.preventDefault();
if (!isCollapsed) {
setIsExpanded(prev => !prev);
}
};
return (
<li>
{/* Main menu item */}
<button
onClick={handleToggle}
className={cn(
"w-full flex items-center px-3 py-2.5 rounded-md text-left transition-all duration-200 group",
isParentOrChildActive
? "bg-slate-100 text-slate-900"
: "text-slate-600 hover:bg-slate-50 hover:text-slate-900",
isCollapsed && "justify-center px-2"
)}
title={isCollapsed ? item.name : undefined}
>
<div className="flex items-center justify-center min-w-[20px]">
<Icon
className={cn(
"h-5 w-5 flex-shrink-0",
isParentOrChildActive
? "text-slate-700"
: "text-slate-500 group-hover:text-slate-700"
)}
/>
</div>
{!isCollapsed && (
<div className="flex items-center justify-between w-full ml-2.5">
<span className={cn("text-sm", isParentOrChildActive ? "font-medium" : "font-normal")}>
{item.name}
</span>
<ChevronDown
className={cn(
"h-4 w-4 text-slate-400 transition-transform duration-200",
isExpanded && "rotate-180"
)}
/>
</div>
)}
{/* Tooltip for collapsed state */}
{isCollapsed && (
<div className="absolute left-full ml-2 px-2 py-1 bg-slate-800 text-white text-xs rounded opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 whitespace-nowrap z-50">
{item.name}
<div className="absolute left-0 top-1/2 transform -translate-y-1/2 -translate-x-1 w-1.5 h-1.5 bg-slate-800 rotate-45" />
</div>
)}
</button>
{/* Submenu items */}
{!isCollapsed && isExpanded && item.subItems && (
<ul className="mt-1 ml-7 space-y-0.5">
{item.subItems.map((subItem) => {
const isSubActive = pathname === subItem.href;
return (
<li key={subItem.id}>
<Link
href={subItem.href}
onClick={onClick}
className={cn(
"block px-3 py-2 rounded-md text-sm transition-colors duration-200",
isSubActive
? "bg-teal-50 text-teal-700 font-medium"
: "text-slate-600 hover:bg-slate-50 hover:text-slate-900"
)}
>
{subItem.name}
</Link>
</li>
);
})}
</ul>
)}
{/* Collapsed state: show submenu on hover */}
{isCollapsed && item.subItems && (
<div className="absolute left-full ml-2 py-2 bg-white border border-slate-200 rounded-lg shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50 min-w-[160px]">
<div className="px-3 py-1.5 text-xs font-medium text-slate-500 border-b border-slate-100 mb-1">
{item.name}
</div>
{item.subItems.map((subItem) => {
const isSubActive = pathname === subItem.href;
return (
<Link
key={subItem.id}
href={subItem.href}
onClick={onClick}
className={cn(
"block px-3 py-2 text-sm transition-colors duration-200",
isSubActive
? "bg-teal-50 text-teal-700 font-medium"
: "text-slate-600 hover:bg-slate-50 hover:text-slate-900"
)}
>
{subItem.name}
</Link>
);
})}
</div>
)}
</li>
);
});
export function EPDSidebar({ className = "", userEmail, userName }: EPDSidebarProps) {
const pathname = usePathname();
const [isCollapsed, setIsCollapsed] = useState(false);
@@ -166,6 +326,12 @@ export function EPDSidebar({ className = "", userEmail, userName }: EPDSidebarPr
if (item.id === 'dashboard') {
return pathname === item.href;
}
// For items with subItems, check if current path matches any subItem
if (item.subItems) {
return item.subItems.some(sub =>
pathname === sub.href || Boolean(pathname?.startsWith(sub.href + '/'))
);
}
return pathname === item.href || Boolean(item.href && pathname?.startsWith(item.href + '/'));
}, [pathname]);
@@ -277,13 +443,24 @@ export function EPDSidebar({ className = "", userEmail, userName }: EPDSidebarPr
<ul className="space-y-1">
{navigationItems.map((item) => (
<SidebarItem
key={item.id}
item={item}
isActive={getIsActive(item)}
isCollapsed={isCollapsed}
onClick={handleItemClick}
/>
item.subItems ? (
<SidebarItemWithSubmenu
key={item.id}
item={item}
isActive={getIsActive(item)}
isCollapsed={effectiveCollapsed}
onClick={handleItemClick}
pathname={pathname}
/>
) : (
<SidebarItem
key={item.id}
item={item}
isActive={getIsActive(item)}
isCollapsed={effectiveCollapsed}
onClick={handleItemClick}
/>
)
))}
</ul>
</nav>

View File

@@ -1,231 +0,0 @@
'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

@@ -1,397 +0,0 @@
'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

@@ -1,141 +0,0 @@
/**
* 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, FileText } 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 justify-between 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>
<Link
href={`/epd/overdracht/${patientId}`}
className="flex items-center gap-2 text-sm text-violet-600 hover:text-violet-700 font-medium transition-colors"
>
<FileText className="h-4 w-4" />
Naar overdracht
</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>
);
}

View File

@@ -1,151 +0,0 @@
/**
* NursingLogsBlock Component
* E5.S3: Dagnotities gemarkeerd voor overdracht
*/
import { ClipboardList, Clock, Pill, Utensils, User, AlertTriangle, FileText, CheckCircle2 } from 'lucide-react';
import type { NursingLog } from '@/lib/types/nursing-log';
import type { NursingLogCategory } from '@/lib/types/nursing-log';
interface NursingLogsBlockProps {
logs: NursingLog[];
}
const CATEGORY_CONFIG: Record<NursingLogCategory, {
label: string;
icon: React.ReactNode;
bgColor: string;
textColor: string;
}> = {
medicatie: {
label: 'Medicatie',
icon: <Pill className="h-3.5 w-3.5" />,
bgColor: 'bg-blue-100',
textColor: 'text-blue-700',
},
adl: {
label: 'ADL',
icon: <Utensils className="h-3.5 w-3.5" />,
bgColor: 'bg-green-100',
textColor: 'text-green-700',
},
gedrag: {
label: 'Gedrag',
icon: <User className="h-3.5 w-3.5" />,
bgColor: 'bg-purple-100',
textColor: 'text-purple-700',
},
incident: {
label: 'Incident',
icon: <AlertTriangle className="h-3.5 w-3.5" />,
bgColor: 'bg-red-100',
textColor: 'text-red-700',
},
observatie: {
label: 'Observatie',
icon: <FileText className="h-3.5 w-3.5" />,
bgColor: 'bg-slate-100',
textColor: 'text-slate-700',
},
};
function formatTime(datetime: string): string {
return new Date(datetime).toLocaleTimeString('nl-NL', {
hour: '2-digit',
minute: '2-digit',
});
}
export function NursingLogsBlock({ logs }: NursingLogsBlockProps) {
// Filter logs marked for handover
const markedLogs = logs.filter(log => log.include_in_handover);
const incidentCount = markedLogs.filter(log => log.category === 'incident').length;
return (
<div className="bg-white rounded-xl border border-slate-200 p-6">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-amber-100 rounded-lg flex items-center justify-center">
<ClipboardList className="h-5 w-5 text-amber-600" />
</div>
<div>
<h2 className="text-lg font-semibold text-slate-900">Dagnotities</h2>
<p className="text-sm text-slate-500">
{markedLogs.length} gemarkeerd voor overdracht
</p>
</div>
</div>
{incidentCount > 0 && (
<span className="px-2.5 py-1 bg-red-100 text-red-700 rounded-full text-xs font-medium">
{incidentCount} incident{incidentCount > 1 ? 'en' : ''}
</span>
)}
</div>
{/* Content */}
{markedLogs.length === 0 ? (
<div className="py-8 text-center">
<ClipboardList className="h-8 w-8 text-slate-300 mx-auto mb-2" />
<p className="text-sm text-slate-500">Geen dagnotities gemarkeerd voor overdracht</p>
{logs.length > 0 && (
<p className="text-xs text-slate-400 mt-1">
{logs.length} notitie{logs.length > 1 ? 's' : ''} niet gemarkeerd
</p>
)}
</div>
) : (
<div className="space-y-3">
{markedLogs.map((log) => {
const config = CATEGORY_CONFIG[log.category as NursingLogCategory] || CATEGORY_CONFIG.observatie;
return (
<div
key={log.id}
className={`
p-3 rounded-lg border-l-4
${log.category === 'incident' ? 'bg-red-50 border-red-400' : 'bg-slate-50 border-slate-300'}
`}
>
{/* Log header */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className={`
inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium
${config.bgColor} ${config.textColor}
`}>
{config.icon}
{config.label}
</span>
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 bg-teal-100 text-teal-700 rounded text-xs">
<CheckCircle2 className="h-3 w-3" />
Overdracht
</span>
</div>
<div className="flex items-center gap-1 text-xs text-slate-500">
<Clock className="h-3 w-3" />
{formatTime(log.timestamp)}
</div>
</div>
{/* Log content */}
<p className="text-sm text-slate-700 leading-relaxed">
{log.content}
</p>
</div>
);
})}
</div>
)}
{/* Footer with total count */}
{logs.length > markedLogs.length && (
<div className="mt-4 pt-3 border-t border-slate-100">
<p className="text-xs text-slate-500">
+ {logs.length - markedLogs.length} andere notitie{logs.length - markedLogs.length > 1 ? 's' : ''} vandaag (niet gemarkeerd)
</p>
</div>
)}
</div>
);
}

View File

@@ -1,150 +0,0 @@
/**
* ReportsBlock Component
* E5.S2: Rapportages laatste 24 uur
*/
import { FileText, Clock, User } from 'lucide-react';
import type { Report } from '@/lib/types/overdracht';
interface ReportsBlockProps {
reports: Report[];
}
function getReportTypeLabel(type: string): string {
const types: Record<string, string> = {
voortgang: 'Voortgang',
observatie: 'Observatie',
incident: 'Incident',
medicatie: 'Medicatie',
contact: 'Contact',
crisis: 'Crisis',
intake: 'Intake',
behandeladvies: 'Behandeladvies',
vrije_notitie: 'Vrije notitie',
};
return types[type] || type;
}
function getReportTypeStyle(type: string): { bg: string; text: string } {
switch (type) {
case 'incident':
case 'crisis':
return { bg: 'bg-red-100', text: 'text-red-700' };
case 'observatie':
return { bg: 'bg-blue-100', text: 'text-blue-700' };
case 'voortgang':
return { bg: 'bg-green-100', text: 'text-green-700' };
case 'medicatie':
return { bg: 'bg-purple-100', text: 'text-purple-700' };
case 'contact':
return { bg: 'bg-amber-100', text: 'text-amber-700' };
case 'intake':
return { bg: 'bg-teal-100', text: 'text-teal-700' };
case 'behandeladvies':
return { bg: 'bg-indigo-100', text: 'text-indigo-700' };
case 'vrije_notitie':
default:
return { bg: 'bg-slate-100', text: 'text-slate-700' };
}
}
function formatDateTime(datetime: string): string {
const date = new Date(datetime);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
if (isToday) {
return date.toLocaleTimeString('nl-NL', {
hour: '2-digit',
minute: '2-digit',
});
}
return date.toLocaleDateString('nl-NL', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
});
}
function truncateContent(content: string, maxLength: number = 150): string {
if (content.length <= maxLength) return content;
return content.substring(0, maxLength).trim() + '...';
}
export function ReportsBlock({ reports }: ReportsBlockProps) {
const incidentCount = reports.filter(r => r.type === 'incident' || r.type === 'crisis').length;
return (
<div className="bg-white rounded-xl border border-slate-200 p-6">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-indigo-100 rounded-lg flex items-center justify-center">
<FileText className="h-5 w-5 text-indigo-600" />
</div>
<div>
<h2 className="text-lg font-semibold text-slate-900">Rapportages</h2>
<p className="text-sm text-slate-500">
{reports.length} rapportages (24u)
</p>
</div>
</div>
{incidentCount > 0 && (
<span className="px-2.5 py-1 bg-red-100 text-red-700 rounded-full text-xs font-medium">
{incidentCount} incident{incidentCount > 1 ? 'en' : ''}
</span>
)}
</div>
{/* Content */}
{reports.length === 0 ? (
<div className="py-8 text-center">
<FileText className="h-8 w-8 text-slate-300 mx-auto mb-2" />
<p className="text-sm text-slate-500">Geen rapportages in de laatste 24 uur</p>
</div>
) : (
<div className="space-y-3">
{reports.map((report) => {
const typeStyle = getReportTypeStyle(report.type);
return (
<div
key={report.id}
className="p-3 bg-slate-50 rounded-lg hover:bg-slate-100 transition-colors"
>
{/* Report header */}
<div className="flex items-center justify-between mb-2">
<span className={`
px-2 py-0.5 rounded text-xs font-medium
${typeStyle.bg} ${typeStyle.text}
`}>
{getReportTypeLabel(report.type)}
</span>
<div className="flex items-center gap-2 text-xs text-slate-500">
<Clock className="h-3 w-3" />
{formatDateTime(report.created_at)}
</div>
</div>
{/* Report content */}
<p className="text-sm text-slate-700 leading-relaxed">
{truncateContent(report.content)}
</p>
{/* Author if available */}
{report.created_by && (
<div className="flex items-center gap-1 mt-2 text-xs text-slate-500">
<User className="h-3 w-3" />
{report.created_by}
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
}

View File

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

View File

@@ -1,116 +0,0 @@
'use client';
/**
* PatientCard Component
* E4.S2: Naam, leeftijd, alert badge (rood=hoog risico), doorklik
*/
import Link from 'next/link';
import { AlertTriangle, User, ChevronRight, Activity, FileText, ShieldAlert } from 'lucide-react';
import type { PatientOverzicht } from '@/lib/types/overdracht';
interface PatientCardProps {
patient: PatientOverzicht;
}
function formatPatientName(nameGiven: string[], nameFamily: string): string {
const given = nameGiven.join(' ');
return `${given} ${nameFamily}`;
}
function calculateAge(birthDate: string): number {
const birth = new Date(birthDate);
const today = new Date();
let age = today.getFullYear() - birth.getFullYear();
const monthDiff = today.getMonth() - birth.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) {
age--;
}
return age;
}
function getGenderLabel(gender: string): string {
switch (gender) {
case 'male': return 'M';
case 'female': return 'V';
default: return 'O';
}
}
export function PatientCard({ patient }: PatientCardProps) {
const name = formatPatientName(patient.name_given, patient.name_family);
const age = calculateAge(patient.birth_date);
const genderLabel = getGenderLabel(patient.gender);
const hasAlerts = patient.alerts.total > 0;
const hasHighRisk = patient.alerts.high_risk_count > 0;
return (
<Link
href={`/epd/overdracht/${patient.id}`}
className={`
block bg-white rounded-xl border transition-all
hover:shadow-md hover:border-slate-300
${hasHighRisk ? 'border-red-200' : 'border-slate-200'}
`}
>
<div className="p-4">
{/* Header with name and alert badge */}
<div className="flex items-start justify-between gap-2 mb-3">
<div className="flex items-center gap-3">
<div className={`
w-10 h-10 rounded-full flex items-center justify-center
${hasHighRisk ? 'bg-red-100' : 'bg-slate-100'}
`}>
<User className={`h-5 w-5 ${hasHighRisk ? 'text-red-600' : 'text-slate-500'}`} />
</div>
<div>
<h3 className="font-medium text-slate-900 line-clamp-1">{name}</h3>
<p className="text-sm text-slate-500">
{age} jaar {genderLabel}
</p>
</div>
</div>
{hasAlerts && (
<span className={`
inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium
${hasHighRisk ? 'bg-red-100 text-red-700' : 'bg-amber-100 text-amber-700'}
`}>
<AlertTriangle className="h-3 w-3" />
{patient.alerts.total}
</span>
)}
</div>
{/* Alert details */}
{hasAlerts && (
<div className="flex flex-wrap gap-2 mb-3">
{patient.alerts.high_risk_count > 0 && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-red-50 text-red-600 rounded text-xs">
<ShieldAlert className="h-3 w-3" />
{patient.alerts.high_risk_count} hoog risico
</span>
)}
{patient.alerts.abnormal_vitals_count > 0 && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-orange-50 text-orange-600 rounded text-xs">
<Activity className="h-3 w-3" />
{patient.alerts.abnormal_vitals_count} afwijkend
</span>
)}
{patient.alerts.marked_logs_count > 0 && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-blue-50 text-blue-600 rounded text-xs">
<FileText className="h-3 w-3" />
{patient.alerts.marked_logs_count} notitie
</span>
)}
</div>
)}
{/* Footer with link indicator */}
<div className="flex items-center justify-between text-sm text-slate-500 pt-2 border-t border-slate-100">
<span>Bekijk overdracht</span>
<ChevronRight className="h-4 w-4" />
</div>
</div>
</Link>
);
}

View File

@@ -1,99 +0,0 @@
'use client';
/**
* PatientGrid Component
* E4.S1: Grid van PatientCards met filter tabs
*/
import { useState } from 'react';
import { PatientCard } from './patient-card';
import type { PatientOverzicht } from '@/lib/types/overdracht';
import { Users, AlertTriangle } from 'lucide-react';
interface PatientGridProps {
patients: PatientOverzicht[];
}
type FilterType = 'all' | 'alerts';
export function PatientGrid({ patients }: PatientGridProps) {
const [filter, setFilter] = useState<FilterType>('all');
const filteredPatients = filter === 'alerts'
? patients.filter(p => p.alerts.total > 0)
: patients;
const alertCount = patients.filter(p => p.alerts.total > 0).length;
return (
<div>
{/* Filter Tabs */}
<div className="flex gap-2 mb-6">
<button
onClick={() => setFilter('all')}
className={`
inline-flex items-center gap-2 px-4 py-2 rounded-lg font-medium text-sm
transition-all
${filter === 'all'
? 'bg-teal-600 text-white shadow-sm'
: 'bg-white text-slate-600 hover:bg-slate-50 border border-slate-200'
}
`}
>
<Users className="h-4 w-4" />
Alle patiënten
<span className={`
px-2 py-0.5 rounded-full text-xs
${filter === 'all' ? 'bg-teal-500 text-white' : 'bg-slate-100 text-slate-600'}
`}>
{patients.length}
</span>
</button>
<button
onClick={() => setFilter('alerts')}
className={`
inline-flex items-center gap-2 px-4 py-2 rounded-lg font-medium text-sm
transition-all
${filter === 'alerts'
? 'bg-teal-600 text-white shadow-sm'
: 'bg-white text-slate-600 hover:bg-slate-50 border border-slate-200'
}
`}
>
<AlertTriangle className="h-4 w-4" />
Met alerts
<span className={`
px-2 py-0.5 rounded-full text-xs
${filter === 'alerts' ? 'bg-teal-500 text-white' : 'bg-red-100 text-red-600'}
`}>
{alertCount}
</span>
</button>
</div>
{/* Patient Grid */}
{filteredPatients.length === 0 ? (
<div className="bg-white rounded-xl border border-slate-200 p-12 text-center">
<div className="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-4">
<Users className="h-8 w-8 text-slate-400" />
</div>
<h3 className="text-lg font-medium text-slate-900 mb-2">
{filter === 'alerts' ? 'Geen patiënten met alerts' : 'Geen patiënten vandaag'}
</h3>
<p className="text-sm text-slate-600">
{filter === 'alerts'
? 'Er zijn geen patiënten met hoog risico, afwijkende vitals of gemarkeerde notities.'
: 'Er zijn geen patiënten met een encounter gepland voor vandaag.'}
</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{filteredPatients.map((patient) => (
<PatientCard key={patient.id} patient={patient} />
))}
</div>
)}
</div>
);
}

View File

@@ -10,8 +10,21 @@ import { Loader2, Trash2 } from 'lucide-react';
import { format } from 'date-fns';
import { nl } from 'date-fns/locale';
const riskTypes = ['Suïcidaliteit', 'Agressie', 'Zelfverwaarlozing', 'Middelenmisbruik', 'Verward gedrag', 'Overig'];
const riskLevels = ['laag', 'gemiddeld', 'hoog', 'zeer_hoog'];
// Database values -> Display labels
const riskTypeOptions = [
{ value: 'suicidaliteit', label: 'Suïcidaliteit' },
{ value: 'agressie', label: 'Agressie' },
{ value: 'zelfverwaarlozing', label: 'Zelfverwaarlozing' },
{ value: 'middelenmisbruik', label: 'Middelenmisbruik' },
{ value: 'verward_gedrag', label: 'Verward gedrag' },
{ value: 'overig', label: 'Overig' },
];
const riskLevelOptions = [
{ value: 'laag', label: 'Laag' },
{ value: 'gemiddeld', label: 'Gemiddeld' },
{ value: 'hoog', label: 'Hoog' },
{ value: 'zeer_hoog', label: 'Zeer hoog' },
];
interface RiskManagerProps {
patientId: string;
@@ -22,8 +35,8 @@ interface RiskManagerProps {
export function RiskManager({ patientId, intakeId, risks }: RiskManagerProps) {
const [form, setForm] = useState({
date: '',
type: riskTypes[0],
level: riskLevels[0],
type: riskTypeOptions[0].value,
level: riskLevelOptions[0].value,
rationale: '',
measures: '',
evaluationDate: '',
@@ -78,13 +91,16 @@ export function RiskManager({ patientId, intakeId, risks }: RiskManagerProps) {
{risks.length === 0 && (
<p className="text-sm text-slate-500">Nog geen risicotaxaties vastgelegd.</p>
)}
{risks.map((risk) => (
{risks.map((risk) => {
const typeLabel = riskTypeOptions.find((o) => o.value === risk.risk_type)?.label ?? risk.risk_type;
const levelLabel = riskLevelOptions.find((o) => o.value === risk.risk_level)?.label ?? risk.risk_level;
return (
<div key={risk.id} className="rounded-lg border border-slate-200 p-3 space-y-1">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-slate-900">{risk.risk_type}</p>
<p className="font-medium text-slate-900">{typeLabel}</p>
<p className="text-xs text-slate-500">
{format(new Date(risk.assessment_date), 'd MMM yyyy', { locale: nl })} {risk.risk_level}
{format(new Date(risk.assessment_date), 'd MMM yyyy', { locale: nl })} {levelLabel}
</p>
</div>
<button
@@ -100,7 +116,7 @@ export function RiskManager({ patientId, intakeId, risks }: RiskManagerProps) {
{risk.measures && <p className="text-xs text-slate-600">Maatregelen: {risk.measures}</p>}
{risk.notes && <p className="text-xs text-slate-500">Notities: {risk.notes}</p>}
</div>
))}
)})}
</div>
<div className="rounded-lg border border-slate-200 p-4 space-y-3">
@@ -117,9 +133,9 @@ export function RiskManager({ patientId, intakeId, risks }: RiskManagerProps) {
onChange={(e) => setForm((prev) => ({ ...prev, type: e.target.value }))}
className="h-10 rounded-md border border-slate-300 px-3 text-sm"
>
{riskTypes.map((type) => (
<option key={type} value={type}>
{type}
{riskTypeOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
@@ -128,9 +144,9 @@ export function RiskManager({ patientId, intakeId, risks }: RiskManagerProps) {
onChange={(e) => setForm((prev) => ({ ...prev, level: e.target.value }))}
className="h-10 rounded-md border border-slate-300 px-3 text-sm"
>
{riskLevels.map((level) => (
<option key={level} value={level}>
{level}
{riskLevelOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>

View File

@@ -1,6 +1,6 @@
'use client'
import { FileText, ClipboardList, Activity, AlertTriangle, Pill, TrendingUp, Phone, Zap } from 'lucide-react'
import { FileText, ClipboardList, TrendingUp, Phone, Zap } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { ReportType } from '@/lib/types/report'
@@ -36,9 +36,6 @@ export interface QuickActionsProps {
const QUICK_ACTIONS: QuickAction[] = [
{ id: 'voortgang', label: 'Voortgang', icon: TrendingUp, type: 'voortgang', color: 'emerald' },
{ id: 'observatie', label: 'Observatie', icon: Activity, type: 'observatie', color: 'blue' },
{ id: 'medicatie', label: 'Medicatie', icon: Pill, type: 'medicatie', color: 'purple' },
{ id: 'incident', label: 'Incident', icon: AlertTriangle, type: 'incident', color: 'red' },
{ id: 'contact', label: 'Contact', icon: Phone, type: 'contact', color: 'amber' },
{ id: 'crisis', label: 'Crisis', icon: Zap, type: 'crisis', color: 'red' },
{ id: 'vrije-notitie', label: 'Vrije notitie', icon: FileText, type: 'vrije_notitie', color: 'slate' },

View File

@@ -1,27 +1,43 @@
/**
* Overdracht Overzicht Page
* E4.S1: Route /epd/overdracht/, grid van PatientCards, filter tabs
* Overdracht Server Actions/Functions
* Gedeelde data fetching functies voor overdracht en dagregistratie
*/
import { createClient } from '@/lib/auth/server';
import { ClipboardList } from 'lucide-react';
import { PatientGrid } from './components/patient-grid';
import type { PatientOverzicht } from '@/lib/types/overdracht';
async function getOverdrachtPatients(date?: string): Promise<{
type PeriodValue = '1d' | '3d' | '7d' | '14d';
function getPeriodDays(period: PeriodValue): number {
switch (period) {
case '1d': return 1;
case '3d': return 3;
case '7d': return 7;
case '14d': return 14;
default: return 7;
}
}
export async function getOverdrachtPatients(period: PeriodValue = '7d'): Promise<{
patients: PatientOverzicht[];
total: number;
date: string;
}> {
const supabase = await createClient();
const targetDate = date || new Date().toISOString().split('T')[0];
const today = new Date();
const targetDate = today.toISOString().split('T')[0];
// Get start and end of day for date filtering (last 24 hours for reports)
const dayStart = `${targetDate}T00:00:00.000Z`;
const dayEnd = `${targetDate}T23:59:59.999Z`;
const last24h = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
// Calculate date range based on period
const days = getPeriodDays(period);
const periodStart = new Date(today);
periodStart.setDate(periodStart.getDate() - (days - 1));
const periodStartISO = periodStart.toISOString();
// 1. Get patients with reports in the last 24 hours
// For vitals/logs, use the period range
const dayStart = periodStart.toISOString().split('T')[0] + 'T00:00:00.000Z';
const dayEnd = targetDate + 'T23:59:59.999Z';
// 1. Get patients with activity in the selected period
const { data: reportsData, error: reportsError } = await supabase
.from('reports')
.select(`
@@ -34,14 +50,10 @@ async function getOverdrachtPatients(date?: string): Promise<{
gender
)
`)
.gte('created_at', last24h);
.gte('created_at', periodStartISO)
.is('deleted_at', null);
if (reportsError) {
console.error('Error fetching reports:', reportsError);
return { patients: [], total: 0, date: targetDate };
}
// Deduplicate patients
// Deduplicate patients from reports
const patientMap = new Map<string, {
id: string;
name_given: string[];
@@ -50,16 +62,65 @@ async function getOverdrachtPatients(date?: string): Promise<{
gender: string;
}>();
for (const report of reportsData || []) {
const patient = report.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);
if (!reportsError && reportsData) {
for (const report of reportsData) {
const patient = report.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);
}
}
}
// Also check for patients with verpleegkundig reports in period
const { data: verpleegkundigPatients } = await supabase
.from('reports')
.select(`
patient_id,
patients!inner (
id,
name_given,
name_family,
birth_date,
gender
)
`)
.eq('type', 'verpleegkundig')
.gte('shift_date', periodStart.toISOString().split('T')[0])
.lte('shift_date', targetDate)
.is('deleted_at', null);
if (verpleegkundigPatients) {
for (const report of verpleegkundigPatients) {
const patient = report.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);
}
}
}
// If no patients with recent activity, get all patients as fallback (for prototype)
if (patientMap.size === 0) {
const { data: allPatients } = await supabase
.from('patients')
.select('id, name_given, name_family, birth_date, gender')
.limit(10);
if (allPatients) {
for (const patient of allPatients) {
patientMap.set(patient.id, patient);
}
}
}
@@ -69,20 +130,20 @@ async function getOverdrachtPatients(date?: string): Promise<{
return { patients: [], total: 0, date: targetDate };
}
// 2. Get alert counts in parallel
// 2. Get alert counts in parallel (within the selected period)
const [
{ data: risksData },
{ data: vitalsData },
{ data: logsData },
] = await Promise.all([
// High risk assessments (via intakes)
// High risk assessments (via intakes) - these are not time-bound
supabase
.from('risk_assessments')
.select('id, intakes!inner(patient_id)')
.in('intakes.patient_id', patientIds)
.in('risk_level', ['hoog', 'zeer_hoog']),
// Abnormal vitals today
// Abnormal vitals in period
supabase
.from('observations')
.select('id, patient_id, interpretation_code')
@@ -92,13 +153,16 @@ async function getOverdrachtPatients(date?: string): Promise<{
.lte('effective_datetime', dayEnd)
.in('interpretation_code', ['H', 'L', 'HH', 'LL']),
// Marked nursing logs for handover
// Marked verpleegkundig reports for handover in period
supabase
.from('nursing_logs')
.from('reports')
.select('id, patient_id')
.in('patient_id', patientIds)
.eq('shift_date', targetDate)
.eq('include_in_handover', true),
.eq('type', 'verpleegkundig')
.gte('shift_date', periodStart.toISOString().split('T')[0])
.lte('shift_date', targetDate)
.eq('include_in_handover', true)
.is('deleted_at', null),
]);
// Count alerts per patient
@@ -182,43 +246,3 @@ async function getOverdrachtPatients(date?: string): Promise<{
date: targetDate,
};
}
export default async function OverdrachtPage() {
const data = await getOverdrachtPatients();
// Format date for display
const displayDate = new Date(data.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-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<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-2xl font-bold text-slate-900">
Overdracht
</h1>
<p className="text-sm text-slate-600">
{displayDate} {data.total} {data.total === 1 ? 'patiënt' : 'patiënten'}
</p>
</div>
</div>
</div>
</div>
{/* Content */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<PatientGrid patients={data.patients} />
</div>
</div>
);
}

View File

@@ -16,9 +16,11 @@ import {
RefreshCw,
} from 'lucide-react';
import type { AISamenvatting, Aandachtspunt } from '@/lib/types/overdracht';
import { type PeriodValue, getPeriodLabel } from '../../lib/period-utils';
interface AISummaryBlockProps {
patientId: string;
period: PeriodValue;
}
function formatDuration(ms: number): string {
@@ -37,7 +39,7 @@ function getBronTypeLabel(type: string): string {
const labels: Record<string, string> = {
observatie: 'Vitale functie',
rapportage: 'Rapportage',
dagnotitie: 'Dagnotitie',
verpleegkundig: 'Verpleegkundig',
risico: 'Risicobeoordeling',
};
return labels[type] || type;
@@ -49,7 +51,7 @@ function getBronTypeStyle(type: string): { bg: string; text: string } {
return { bg: 'bg-teal-100', text: 'text-teal-700' };
case 'rapportage':
return { bg: 'bg-indigo-100', text: 'text-indigo-700' };
case 'dagnotitie':
case 'verpleegkundig':
return { bg: 'bg-amber-100', text: 'text-amber-700' };
case 'risico':
return { bg: 'bg-red-100', text: 'text-red-700' };
@@ -58,7 +60,7 @@ function getBronTypeStyle(type: string): { bg: string; text: string } {
}
}
export function AISummaryBlock({ patientId }: AISummaryBlockProps) {
export function AISummaryBlock({ patientId, period }: AISummaryBlockProps) {
const [summary, setSummary] = useState<AISamenvatting | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -71,7 +73,7 @@ export function AISummaryBlock({ patientId }: AISummaryBlockProps) {
const response = await fetch('/api/overdracht/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ patientId }),
body: JSON.stringify({ patientId, period }),
});
if (!response.ok) {
@@ -98,7 +100,7 @@ export function AISummaryBlock({ patientId }: AISummaryBlockProps) {
<div>
<h2 className="text-lg font-semibold text-slate-900">AI Samenvatting</h2>
<p className="text-sm text-slate-500">
Gegenereerd met Claude AI
{getPeriodLabel(period)}
</p>
</div>
</div>
@@ -108,7 +110,7 @@ export function AISummaryBlock({ patientId }: AISummaryBlockProps) {
<div className="py-6 text-center">
<Sparkles className="h-10 w-10 text-slate-300 mx-auto mb-3" />
<p className="text-sm text-slate-600 mb-4">
Genereer een beknopte overdracht samenvatting op basis van alle beschikbare patiëntgegevens.
Genereer een beknopte overdracht samenvatting op basis van de beschikbare verpleegrapportages.
</p>
<button
onClick={generateSummary}
@@ -222,8 +224,48 @@ export function AISummaryBlock({ patientId }: AISummaryBlockProps) {
);
}
/**
* Get anchor href for a source reference
* Returns null if source cannot be linked (e.g., observations, risks)
*/
function getSourceElementId(bron: Aandachtspunt['bron']): string | null {
// bron.id format: "reports/uuid" or "observations/uuid" etc.
const parts = bron.id.split('/');
if (parts.length !== 2) return null;
const [table, uuid] = parts;
// Only reports have anchor targets in the current UI
if (table === 'reports') {
return `report-${uuid}`;
}
return null;
}
/**
* Scroll to source element and highlight it
*/
function scrollToSource(elementId: string) {
const el = document.getElementById(elementId);
if (!el) return;
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
// Add highlight effect
el.classList.add('ring-2', 'ring-violet-400', 'ring-offset-2');
setTimeout(() => {
el.classList.remove('ring-2', 'ring-violet-400', 'ring-offset-2');
}, 2000);
}
function AandachtspuntItem({ punt }: { punt: Aandachtspunt }) {
const bronStyle = getBronTypeStyle(punt.bron.type);
const sourceElementId = getSourceElementId(punt.bron);
const isClickable = sourceElementId !== null;
const handleClick = () => {
if (sourceElementId) {
scrollToSource(sourceElementId);
}
};
return (
<div
@@ -241,13 +283,28 @@ function AandachtspuntItem({ punt }: { punt: Aandachtspunt }) {
</p>
</div>
<div className="flex items-center gap-2 mt-2">
<span className={`
inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs
${bronStyle.bg} ${bronStyle.text}
`}>
<ExternalLink className="h-3 w-3" />
{getBronTypeLabel(punt.bron.type)}
</span>
{isClickable ? (
<button
onClick={handleClick}
className={`
inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs
cursor-pointer hover:opacity-80 transition-opacity
${bronStyle.bg} ${bronStyle.text}
`}
title="Klik om naar bron te scrollen"
>
<ExternalLink className="h-3 w-3" />
{getBronTypeLabel(punt.bron.type)}
</button>
) : (
<span className={`
inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs
${bronStyle.bg} ${bronStyle.text}
`}>
<ExternalLink className="h-3 w-3" />
{getBronTypeLabel(punt.bron.type)}
</span>
)}
<span className="text-xs text-slate-500">
{punt.bron.label} {punt.bron.datum}
</span>

View File

@@ -0,0 +1,308 @@
/**
* ReportsBlock Component - Timeline UI
* Visuele timeline voor rapportages
* Gegroepeerd per dag met dagdeel headers
*/
import { FileText, Clock, User, Pill, Utensils, AlertTriangle, CheckCircle2, Sun, Sunrise, Sunset, Moon } from 'lucide-react';
import { format, isToday, isYesterday } from 'date-fns';
import { nl } from 'date-fns/locale';
import type { Report } from '@/lib/types/overdracht';
import { getVerpleegkundigCategory, CATEGORY_CONFIG, type VerpleegkundigCategory } from '@/lib/types/report';
interface ReportsBlockProps {
reports: Report[];
}
function getReportTypeLabel(type: string): string {
const types: Record<string, string> = {
voortgang: 'Voortgang',
observatie: 'Observatie',
incident: 'Incident',
medicatie: 'Medicatie',
contact: 'Contact',
crisis: 'Crisis',
intake: 'Intake',
behandeladvies: 'Behandeladvies',
vrije_notitie: 'Vrije notitie',
verpleegkundig: 'Verpleegkundig',
};
return types[type] || type;
}
function getReportTypeStyle(type: string): { bg: string; text: string } {
switch (type) {
case 'incident':
case 'crisis':
return { bg: 'bg-red-100', text: 'text-red-700' };
case 'observatie':
return { bg: 'bg-blue-100', text: 'text-blue-700' };
case 'voortgang':
return { bg: 'bg-green-100', text: 'text-green-700' };
case 'medicatie':
return { bg: 'bg-purple-100', text: 'text-purple-700' };
case 'contact':
return { bg: 'bg-amber-100', text: 'text-amber-700' };
case 'intake':
return { bg: 'bg-teal-100', text: 'text-teal-700' };
case 'behandeladvies':
return { bg: 'bg-indigo-100', text: 'text-indigo-700' };
case 'verpleegkundig':
return { bg: 'bg-amber-100', text: 'text-amber-700' };
case 'vrije_notitie':
default:
return { bg: 'bg-slate-100', text: 'text-slate-700' };
}
}
function getCategoryIcon(category: VerpleegkundigCategory | null) {
if (!category) return null;
switch (category) {
case 'medicatie':
return <Pill className="h-3 w-3" />;
case 'adl':
return <Utensils className="h-3 w-3" />;
case 'gedrag':
return <User className="h-3 w-3" />;
case 'incident':
return <AlertTriangle className="h-3 w-3" />;
case 'observatie':
return <FileText className="h-3 w-3" />;
default:
return null;
}
}
function truncateContent(content: string, maxLength: number = 150): string {
if (content.length <= maxLength) return content;
return content.substring(0, maxLength).trim() + '...';
}
// Dagdeel helpers
type DayPart = 'nacht' | 'ochtend' | 'middag' | 'avond';
function getDayPart(date: Date): DayPart {
const hour = date.getHours();
if (hour >= 0 && hour < 7) return 'nacht';
if (hour >= 7 && hour < 12) return 'ochtend';
if (hour >= 12 && hour < 17) return 'middag';
return 'avond';
}
const DAY_PART_CONFIG: Record<DayPart, { label: string; icon: React.ComponentType<{ className?: string }>; color: string }> = {
nacht: { label: 'Nacht', icon: Moon, color: 'text-indigo-600' },
ochtend: { label: 'Ochtend', icon: Sunrise, color: 'text-amber-600' },
middag: { label: 'Middag', icon: Sun, color: 'text-yellow-600' },
avond: { label: 'Avond', icon: Sunset, color: 'text-orange-600' },
};
// Group reports by day and day part
interface GroupedReports {
date: string;
dateLabel: string;
dayParts: {
part: DayPart;
reports: Report[];
}[];
}
function groupReportsByDayAndPart(reports: Report[]): GroupedReports[] {
const byDay = new Map<string, Report[]>();
reports.forEach(report => {
const date = new Date(report.created_at);
const dayKey = format(date, 'yyyy-MM-dd');
if (!byDay.has(dayKey)) {
byDay.set(dayKey, []);
}
byDay.get(dayKey)!.push(report);
});
const sortedDays = Array.from(byDay.entries()).sort((a, b) => b[0].localeCompare(a[0]));
return sortedDays.map(([dayKey, dayReports]) => {
const date = new Date(dayKey);
let dateLabel: string;
if (isToday(date)) {
dateLabel = 'Vandaag';
} else if (isYesterday(date)) {
dateLabel = 'Gisteren';
} else {
dateLabel = format(date, 'EEEE d MMMM', { locale: nl });
}
const byPart = new Map<DayPart, Report[]>();
dayReports.forEach(report => {
const reportDate = new Date(report.created_at);
const part = getDayPart(reportDate);
if (!byPart.has(part)) {
byPart.set(part, []);
}
byPart.get(part)!.push(report);
});
const partOrder: DayPart[] = ['avond', 'middag', 'ochtend', 'nacht'];
const dayParts = partOrder
.filter(part => byPart.has(part))
.map(part => ({
part,
reports: byPart.get(part)!.sort((a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
),
}));
return { date: dayKey, dateLabel, dayParts };
});
}
export function ReportsBlock({ reports }: ReportsBlockProps) {
const incidentCount = reports.filter(r => r.type === 'incident' || r.type === 'crisis').length;
const handoverCount = reports.filter(r => r.include_in_handover).length;
const groupedReports = groupReportsByDayAndPart(reports);
return (
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
{/* Header */}
<div className="p-4 bg-slate-50 border-b border-slate-200">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-indigo-100 rounded-lg flex items-center justify-center">
<FileText className="h-5 w-5 text-indigo-600" />
</div>
<div>
<h2 className="text-lg font-semibold text-slate-900">Rapportages</h2>
<p className="text-sm text-slate-500">
{reports.length} rapportages
</p>
</div>
</div>
<div className="flex items-center gap-2">
{handoverCount > 0 && (
<span className="px-2.5 py-1 bg-teal-100 text-teal-700 rounded-full text-xs font-medium">
{handoverCount} overdracht
</span>
)}
{incidentCount > 0 && (
<span className="px-2.5 py-1 bg-red-100 text-red-700 rounded-full text-xs font-medium">
{incidentCount} incident{incidentCount > 1 ? 'en' : ''}
</span>
)}
</div>
</div>
</div>
{/* Content */}
{reports.length === 0 ? (
<div className="py-8 text-center">
<FileText className="h-8 w-8 text-slate-300 mx-auto mb-2" />
<p className="text-sm text-slate-500">Geen rapportages gevonden</p>
</div>
) : (
<div className="divide-y divide-slate-100">
{groupedReports.map((dayGroup) => (
<div key={dayGroup.date}>
{/* Day Header */}
<div className="px-4 py-2 bg-slate-50/50 border-b border-slate-100">
<span className="text-sm font-medium text-slate-700 capitalize">
{dayGroup.dateLabel}
</span>
</div>
{/* Day Parts with Timeline */}
{dayGroup.dayParts.map((partGroup) => {
const PartIcon = DAY_PART_CONFIG[partGroup.part].icon;
return (
<div key={partGroup.part}>
{/* Day Part Header */}
<div className="flex items-center gap-2 px-4 py-1.5 bg-slate-50/30">
<PartIcon className={`h-3.5 w-3.5 ${DAY_PART_CONFIG[partGroup.part].color}`} />
<span className="text-xs font-medium text-slate-500">
{DAY_PART_CONFIG[partGroup.part].label}
</span>
</div>
{/* Timeline with reports */}
<div className="relative pl-8">
{/* Vertical timeline line */}
<div className="absolute left-5 top-0 bottom-0 w-0.5 bg-slate-200" />
{partGroup.reports.map((report, idx) => {
const typeStyle = getReportTypeStyle(report.type);
const category = report.type === 'verpleegkundig'
? getVerpleegkundigCategory(report.structured_data)
: null;
const categoryConfig = category ? CATEGORY_CONFIG[category] : null;
const isIncident = report.type === 'incident' || report.type === 'crisis' || category === 'incident';
const time = format(new Date(report.created_at), 'HH:mm', { locale: nl });
const isLast = idx === partGroup.reports.length - 1;
return (
<div
key={report.id}
id={`report-${report.id}`}
className={`relative py-3 pr-4 transition-all ${!isLast ? 'border-b border-slate-50' : ''}`}
>
{/* Timeline node */}
<div className={`absolute -left-3 top-4 w-3 h-3 rounded-full border-2 border-white shadow-sm ${
isIncident ? 'bg-red-500' : 'bg-indigo-400'
}`} />
{/* Report content */}
<div className="ml-2">
{/* Header row */}
<div className="flex items-center gap-2 mb-1 flex-wrap">
{/* Time */}
<span className="text-xs font-medium text-slate-500 w-10">{time}</span>
{/* Type badge - verberg voor verpleegkundig (redundant in dit scherm) */}
{report.type !== 'verpleegkundig' && (
<span className={`px-2 py-0.5 rounded text-xs font-medium ${typeStyle.bg} ${typeStyle.text}`}>
{getReportTypeLabel(report.type)}
</span>
)}
{/* Category badge for verpleegkundig - altijd tonen */}
{category && categoryConfig && (
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium ${categoryConfig.bgColor} ${categoryConfig.textColor}`}>
{getCategoryIcon(category)}
{categoryConfig.label}
</span>
)}
{/* Overdracht badge */}
{report.include_in_handover && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 bg-teal-100 text-teal-700 rounded text-xs">
<CheckCircle2 className="h-3 w-3" />
</span>
)}
</div>
{/* Report content */}
<p className="text-sm text-slate-700 leading-relaxed">
{truncateContent(report.content)}
</p>
{/* Author if available */}
{report.created_by && (
<div className="flex items-center gap-1 mt-1 text-xs text-slate-400">
<User className="h-3 w-3" />
{report.created_by}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,239 @@
'use client';
/**
* PatientDetail Component - Geoptimaliseerde UX
*
* Layout verbeteringen:
* 1. Compacte patient header
* 2. Risico alerts direct bovenaan (prominent)
* 3. Geen Vitale functies blok (niet geïmplementeerd)
* 4. Rapportages timeline als hoofdcontent
* 5. AI samenvatting in sidebar
*/
import { useEffect, useState, useCallback } from 'react';
import { Loader2, AlertTriangle, ExternalLink } from 'lucide-react';
import Link from 'next/link';
import type { PatientDetail as PatientDetailType } from '@/lib/types/overdracht';
import type { PeriodValue } from '../lib/period-utils';
import { ReportsBlock } from './blocks/reports-block';
import { AISummaryBlock } from './blocks/ai-summary-block';
interface PatientDetailProps {
patientId: string;
period: PeriodValue;
}
function formatPatientName(
nameGiven: string[],
nameFamily: string,
namePrefix?: string
): string {
const given = nameGiven.join(' ');
if (namePrefix) {
return `${given} ${namePrefix} ${nameFamily}`;
}
return `${given} ${nameFamily}`;
}
function calculateAge(birthDate: string): number {
const birth = new Date(birthDate);
const today = new Date();
let age = today.getFullYear() - birth.getFullYear();
const monthDiff = today.getMonth() - birth.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) {
age--;
}
return age;
}
function getGenderLabel(gender: string): string {
switch (gender) {
case 'male': return 'Man';
case 'female': return 'Vrouw';
default: return 'Onbekend';
}
}
function getRiskLevelLabel(level: string): string {
switch (level) {
case 'zeer_hoog': return 'Zeer hoog';
case 'hoog': return 'Hoog';
case 'matig': return 'Matig';
case 'laag': return 'Laag';
default: return level;
}
}
export function PatientDetail({ patientId, period }: PatientDetailProps) {
const [data, setData] = useState<PatientDetailType | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchData = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/verpleegrapportage/${patientId}?periode=${period}`);
if (!response.ok) {
throw new Error('Kon patiëntgegevens niet ophalen');
}
const result = await response.json();
setData(result);
} catch (err) {
setError(err instanceof Error ? err.message : 'Onbekende fout');
} finally {
setLoading(false);
}
}, [patientId, period]);
useEffect(() => {
fetchData();
}, [fetchData]);
if (loading) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<Loader2 className="h-8 w-8 text-teal-600 mx-auto mb-3 animate-spin" />
<p className="text-sm text-slate-600">Gegevens laden...</p>
</div>
</div>
);
}
if (error || !data) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<AlertTriangle className="h-10 w-10 text-red-400 mx-auto mb-3" />
<p className="text-sm text-slate-600 mb-2">{error || 'Geen gegevens gevonden'}</p>
<button
onClick={fetchData}
className="text-sm text-teal-600 hover:text-teal-700 font-medium"
>
Opnieuw proberen
</button>
</div>
</div>
);
}
const { patient, reports, risks } = data;
const patientName = formatPatientName(
patient.name_given,
patient.name_family,
patient.name_prefix
);
const age = calculateAge(patient.birth_date);
const genderLabel = getGenderLabel(patient.gender);
// Filter voor belangrijke alerts
const markedReports = reports.filter(r => r.include_in_handover);
const highRisks = risks.filter(r => r.risk_level === 'hoog' || r.risk_level === 'zeer_hoog');
const incidents = reports.filter(r => r.type === 'incident' || r.type === 'crisis');
return (
<div className="h-full overflow-y-auto">
{/* Compact Patient Header - zonder avatar */}
<div className="sticky top-0 z-10 bg-white border-b border-slate-200 px-6 py-3">
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg font-semibold text-slate-900">{patientName}</h1>
<p className="text-sm text-slate-500">{age} jaar {genderLabel}</p>
</div>
{/* Quick badges + actions */}
<div className="flex items-center gap-2">
{highRisks.length > 0 && (
<span className="px-2.5 py-1 bg-red-100 text-red-700 rounded-full text-xs font-medium">
{highRisks.length} hoog risico
</span>
)}
{markedReports.length > 0 && (
<span className="px-2.5 py-1 bg-teal-100 text-teal-700 rounded-full text-xs font-medium">
{markedReports.length} overdracht
</span>
)}
<Link
href={`/epd/patients/${patientId}`}
className="inline-flex items-center gap-1 px-3 py-1.5 bg-slate-100 text-slate-600 rounded-md text-sm font-medium hover:bg-slate-200 transition-colors"
>
<ExternalLink className="h-3.5 w-3.5" />
Dossier
</Link>
</div>
</div>
</div>
{/* Content */}
<div className="p-6">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left column - Main content */}
<div className="lg:col-span-2 space-y-4">
{/* Risico's Alert Block - Bovenaan en prominent */}
{highRisks.length > 0 && (
<div className="bg-red-50 border border-red-200 rounded-xl p-4">
<div className="flex items-start gap-3">
<div className="w-10 h-10 bg-red-100 rounded-lg flex items-center justify-center flex-shrink-0">
<AlertTriangle className="h-5 w-5 text-red-600" />
</div>
<div className="flex-1">
<h2 className="font-semibold text-red-900 mb-2">
Hoge risico&apos;s ({highRisks.length})
</h2>
<div className="space-y-2">
{highRisks.map((risk) => (
<div
key={risk.id}
className="flex items-start gap-2 text-sm"
>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
risk.risk_level === 'zeer_hoog'
? 'bg-red-200 text-red-800'
: 'bg-red-100 text-red-700'
}`}>
{getRiskLevelLabel(risk.risk_level)}
</span>
<div>
<span className="font-medium text-red-900">{risk.risk_type}</span>
{risk.rationale && (
<p className="text-red-700 mt-0.5">{risk.rationale}</p>
)}
</div>
</div>
))}
</div>
</div>
</div>
</div>
)}
{/* Incidenten waarschuwing */}
{incidents.length > 0 && (
<div className="bg-orange-50 border border-orange-200 rounded-lg p-3 flex items-center gap-3">
<AlertTriangle className="h-5 w-5 text-orange-600 flex-shrink-0" />
<span className="text-sm text-orange-800">
<span className="font-medium">{incidents.length} incident{incidents.length > 1 ? 'en' : ''}</span> in deze periode
</span>
</div>
)}
{/* Rapportages Timeline */}
<ReportsBlock reports={reports} />
</div>
{/* Right column - AI Summary */}
<div className="lg:col-span-1">
<div className="lg:sticky lg:top-24">
<AISummaryBlock patientId={patientId} period={period} />
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,112 @@
'use client';
/**
* PatientListRow Component
* Compacte rij voor een patiënt in de lijst
*/
import { cn } from '@/lib/utils';
import { AlertTriangle, Activity, CheckCircle2 } from 'lucide-react';
import type { PatientOverzicht } from '@/lib/types/overdracht';
interface PatientListRowProps {
patient: PatientOverzicht;
isSelected: boolean;
onClick: () => void;
}
function formatPatientName(
nameGiven: string[],
nameFamily: string
): string {
const given = nameGiven[0] || '';
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 function PatientListRow({ patient, isSelected, onClick }: PatientListRowProps) {
const name = formatPatientName(patient.name_given, patient.name_family);
const age = calculateAge(patient.birth_date);
const hasHighRisk = patient.alerts.high_risk_count > 0;
const hasAbnormalVitals = patient.alerts.abnormal_vitals_count > 0;
const hasMarkedLogs = patient.alerts.marked_logs_count > 0;
return (
<button
onClick={onClick}
className={cn(
"w-full flex items-center gap-3 px-3 py-3 text-left transition-all duration-150 border-l-4",
isSelected
? "bg-teal-50 border-teal-500"
: "bg-white border-transparent hover:bg-slate-50",
hasHighRisk && !isSelected && "border-l-red-400"
)}
>
{/* Avatar */}
<div className={cn(
"w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 text-sm font-medium",
hasHighRisk
? "bg-red-100 text-red-700"
: isSelected
? "bg-teal-100 text-teal-700"
: "bg-slate-100 text-slate-600"
)}>
{patient.name_given[0]?.[0]}{patient.name_family[0]}
</div>
{/* Name and age */}
<div className="flex-1 min-w-0">
<p className={cn(
"text-sm font-medium truncate",
isSelected ? "text-teal-900" : "text-slate-900"
)}>
{name}
</p>
<p className="text-xs text-slate-500">
{age} jaar {patient.gender === 'male' ? 'M' : 'V'}
</p>
</div>
{/* Alert badges */}
<div className="flex items-center gap-1.5 flex-shrink-0">
{hasHighRisk && (
<span
className="flex items-center gap-0.5 px-1.5 py-0.5 bg-red-100 text-red-700 rounded text-xs font-medium"
title={`${patient.alerts.high_risk_count} hoog risico${patient.alerts.high_risk_count > 1 ? "'s" : ''}`}
>
<AlertTriangle className="h-3 w-3" />
{patient.alerts.high_risk_count}
</span>
)}
{hasAbnormalVitals && (
<span
className="flex items-center gap-0.5 px-1.5 py-0.5 bg-orange-100 text-orange-700 rounded text-xs font-medium"
title={`${patient.alerts.abnormal_vitals_count} afwijkende vitale waarde${patient.alerts.abnormal_vitals_count > 1 ? 'n' : ''}`}
>
<Activity className="h-3 w-3" />
{patient.alerts.abnormal_vitals_count}
</span>
)}
{hasMarkedLogs && (
<span
className="flex items-center gap-0.5 px-1.5 py-0.5 bg-teal-100 text-teal-700 rounded text-xs font-medium"
title={`${patient.alerts.marked_logs_count} voor overdracht`}
>
<CheckCircle2 className="h-3 w-3" />
{patient.alerts.marked_logs_count}
</span>
)}
</div>
</button>
);
}

View File

@@ -0,0 +1,104 @@
'use client';
/**
* PatientList Component
* Linker kolom met patiëntenlijst en filter tabs
*/
import { useState } from 'react';
import { cn } from '@/lib/utils';
import { Users, AlertTriangle } from 'lucide-react';
import type { PatientOverzicht } from '@/lib/types/overdracht';
import { PatientListRow } from './patient-list-row';
import { PeriodSelector } from './period-selector';
interface PatientListProps {
patients: PatientOverzicht[];
selectedPatientId: string | null;
onSelectPatient: (patientId: string) => void;
}
type FilterType = 'all' | 'alerts';
export function PatientList({ patients, selectedPatientId, onSelectPatient }: PatientListProps) {
const [filter, setFilter] = useState<FilterType>('all');
// Filter patients based on selected filter
// "Met alerts" = alleen echte alerts (hoge risico's + afwijkende vitals), niet overdracht notities
const hasRealAlerts = (p: PatientOverzicht) =>
p.alerts.high_risk_count > 0 || p.alerts.abnormal_vitals_count > 0;
const filteredPatients = filter === 'alerts'
? patients.filter(hasRealAlerts)
: patients;
const alertsCount = patients.filter(hasRealAlerts).length;
return (
<div className="flex flex-col h-full">
{/* Header with filter tabs */}
<div className="p-4 border-b border-slate-200 bg-white">
<div className="flex items-center gap-2 mb-3">
<Users className="h-5 w-5 text-slate-500" />
<h2 className="font-semibold text-slate-900">Patiënten</h2>
<span className="text-sm text-slate-500">({patients.length})</span>
</div>
{/* Periode selector */}
<div className="mb-3">
<PeriodSelector />
</div>
{/* Filter tabs */}
<div className="flex gap-2">
<button
onClick={() => setFilter('all')}
className={cn(
"px-3 py-1.5 text-sm font-medium rounded-md transition-colors",
filter === 'all'
? "bg-slate-900 text-white"
: "bg-slate-100 text-slate-600 hover:bg-slate-200"
)}
>
Alle ({patients.length})
</button>
<button
onClick={() => setFilter('alerts')}
className={cn(
"px-3 py-1.5 text-sm font-medium rounded-md transition-colors flex items-center gap-1.5",
filter === 'alerts'
? "bg-red-600 text-white"
: "bg-red-50 text-red-700 hover:bg-red-100"
)}
>
<AlertTriangle className="h-3.5 w-3.5" />
Met alerts ({alertsCount})
</button>
</div>
</div>
{/* Patient list */}
<div className="flex-1 overflow-y-auto divide-y divide-slate-100">
{filteredPatients.length > 0 ? (
filteredPatients.map((patient) => (
<PatientListRow
key={patient.id}
patient={patient}
isSelected={selectedPatientId === patient.id}
onClick={() => onSelectPatient(patient.id)}
/>
))
) : (
<div className="p-8 text-center">
<Users className="h-10 w-10 text-slate-300 mx-auto mb-3" />
<p className="text-sm text-slate-500">
{filter === 'alerts'
? 'Geen patiënten met alerts'
: 'Geen patiënten gevonden'}
</p>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,60 @@
'use client';
/**
* PeriodSelector Component voor Verpleegrapportage
* Periode selectie die URL params behoudt (patient + periode)
*/
import { useRouter, useSearchParams } from 'next/navigation';
import { Calendar } from 'lucide-react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { PERIOD_OPTIONS, type PeriodValue } from '../lib/period-utils';
export function PeriodSelector() {
const router = useRouter();
const searchParams = useSearchParams();
const currentPeriod = (searchParams.get('periode') as PeriodValue) || '1d';
const handlePeriodChange = (value: PeriodValue) => {
const params = new URLSearchParams(searchParams.toString());
// Keep patient param if present
if (value === '1d') {
params.delete('periode'); // Default is 24 uur
} else {
params.set('periode', value);
}
const queryString = params.toString();
router.push(`/epd/verpleegrapportage/overdracht${queryString ? `?${queryString}` : ''}`, { scroll: false });
};
const currentOption = PERIOD_OPTIONS.find((o) => o.value === currentPeriod);
return (
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-slate-500" />
<Select value={currentPeriod} onValueChange={handlePeriodChange}>
<SelectTrigger className="w-[160px] h-9 bg-white border-slate-200">
<SelectValue placeholder="Selecteer periode">
{currentOption?.label}
</SelectValue>
</SelectTrigger>
<SelectContent>
{PERIOD_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div className="flex flex-col">
<span>{option.label}</span>
<span className="text-xs text-slate-500">{option.description}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}

View File

@@ -0,0 +1,86 @@
'use client';
/**
* VerpleegrapportageClient Component
* Client wrapper voor master-detail layout met state management
*/
import { useState, useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Users } from 'lucide-react';
import type { PatientOverzicht } from '@/lib/types/overdracht';
import type { PeriodValue } from '../lib/period-utils';
import { PatientList } from './patient-list';
import { PatientDetail } from './patient-detail';
interface VerpleegrapportageClientProps {
initialPatients: PatientOverzicht[];
initialPatientId: string | null;
initialPeriod: PeriodValue;
}
export function VerpleegrapportageClient({
initialPatients,
initialPatientId,
initialPeriod,
}: VerpleegrapportageClientProps) {
const router = useRouter();
const searchParams = useSearchParams();
// State
const [selectedPatientId, setSelectedPatientId] = useState<string | null>(initialPatientId);
const [period, setPeriod] = useState<PeriodValue>(initialPeriod);
// Sync period from URL
useEffect(() => {
const urlPeriod = searchParams.get('periode') as PeriodValue | null;
// Default to '1d' (24 uur) when no periode param in URL
const newPeriod = urlPeriod && ['1d', '3d', '7d', '14d'].includes(urlPeriod)
? urlPeriod
: '1d';
setPeriod(newPeriod);
}, [searchParams]);
// Handle patient selection
const handleSelectPatient = (patientId: string) => {
setSelectedPatientId(patientId);
// Update URL with patient param
const params = new URLSearchParams(searchParams.toString());
params.set('patient', patientId);
router.push(`/epd/verpleegrapportage/overdracht?${params.toString()}`, { scroll: false });
};
return (
<div className="h-screen flex bg-slate-50">
{/* Main content - Master-detail layout (geen header meer) */}
{/* Left panel - Patient list */}
<aside className="w-80 border-r border-slate-200 bg-white flex-shrink-0 overflow-hidden">
<PatientList
patients={initialPatients}
selectedPatientId={selectedPatientId}
onSelectPatient={handleSelectPatient}
/>
</aside>
{/* Right panel - Patient detail */}
<main className="flex-1 overflow-hidden">
{selectedPatientId ? (
<PatientDetail patientId={selectedPatientId} period={period} />
) : (
<div className="h-full flex items-center justify-center">
<div className="text-center">
<Users className="h-16 w-16 text-slate-200 mx-auto mb-4" />
<h2 className="text-lg font-medium text-slate-600 mb-2">
Selecteer een patiënt
</h2>
<p className="text-sm text-slate-500">
Klik op een patiënt in de lijst om de details te bekijken
</p>
</div>
</div>
)}
</main>
</div>
);
}

View File

@@ -0,0 +1,52 @@
/**
* Period utilities voor Overdracht
* Gedeelde functies voor server en client components
*/
export type PeriodValue = '1d' | '3d' | '7d' | '14d';
export interface PeriodOption {
value: PeriodValue;
label: string;
description: string;
}
export const PERIOD_OPTIONS: PeriodOption[] = [
{ value: '1d', label: 'Vandaag', description: 'Laatste 24 uur' },
{ value: '3d', label: '3 dagen', description: 'Afgelopen 3 dagen' },
{ value: '7d', label: '1 week', description: 'Afgelopen 7 dagen' },
{ value: '14d', label: '2 weken', description: 'Afgelopen 14 dagen' },
];
/**
* Helper functie om datumbereik te berekenen op basis van periode
*/
export function getPeriodDays(period: PeriodValue): number {
switch (period) {
case '1d': return 1;
case '3d': return 3;
case '7d': return 7;
case '14d': return 14;
default: return 7;
}
}
export function getPeriodDateRange(period: PeriodValue): {
startDate: string;
endDate: string;
} {
const today = new Date();
const endDate = today.toISOString().split('T')[0];
const days = getPeriodDays(period);
const startDateTime = new Date(today);
startDateTime.setDate(startDateTime.getDate() - (days - 1));
const startDate = startDateTime.toISOString().split('T')[0];
return { startDate, endDate };
}
export function getPeriodLabel(period: PeriodValue): string {
const option = PERIOD_OPTIONS.find((o) => o.value === period);
return option?.description || 'Afgelopen 7 dagen';
}

View File

@@ -0,0 +1,61 @@
/**
* Overdracht Page
* Master-detail layout met patiëntenlijst en detail panel voor overdracht
*/
import { Suspense } from 'react';
import { VerpleegrapportageClient } from '../components/verpleegrapportage-client';
import { getOverdrachtPatients } from '../actions';
import type { PeriodValue } from '../lib/period-utils';
import { Loader2 } from 'lucide-react';
interface PageProps {
searchParams: Promise<{ patient?: string; periode?: string }>;
}
function LoadingState() {
return (
<div className="h-screen flex items-center justify-center bg-slate-50">
<div className="text-center">
<Loader2 className="h-8 w-8 text-teal-600 mx-auto mb-3 animate-spin" />
<p className="text-sm text-slate-600">Laden...</p>
</div>
</div>
);
}
async function OverdrachtContent({ searchParams }: PageProps) {
const { patient, periode } = await searchParams;
// Validate period parameter - default to '1d' (24 uur)
const validPeriods: PeriodValue[] = ['1d', '3d', '7d', '14d'];
const period: PeriodValue = validPeriods.includes(periode as PeriodValue)
? (periode as PeriodValue)
: '1d';
// Fetch patients data
const data = await getOverdrachtPatients(period);
// Determine initial patient selection
const initialPatientId = patient && data.patients.some(p => p.id === patient)
? patient
: data.patients.length > 0
? data.patients[0].id
: null;
return (
<VerpleegrapportageClient
initialPatients={data.patients}
initialPatientId={initialPatientId}
initialPeriod={period}
/>
);
}
export default async function OverdrachtPage({ searchParams }: PageProps) {
return (
<Suspense fallback={<LoadingState />}>
<OverdrachtContent searchParams={searchParams} />
</Suspense>
);
}

View File

@@ -0,0 +1,35 @@
/**
* Rapportage Page (default landing page)
* Centrale pagina voor verpleegkundige rapportage met patiënt selector
* Toont alleen notities van vandaag (invoerpagina)
*/
import { PenLine } from 'lucide-react';
import { getOverdrachtPatients } from './actions';
import { RapportageWorkspace } from './rapportage/components/rapportage-workspace';
export default async function RapportagePage() {
const { patients } = await getOverdrachtPatients();
return (
<div className="h-screen bg-slate-50">
{/* Workspace */}
{patients.length === 0 ? (
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="bg-white rounded-lg border border-slate-200 p-8 text-center">
<PenLine className="h-12 w-12 text-slate-300 mx-auto mb-4" />
<p className="text-slate-600 mb-2">
Geen patiënten met recente activiteit
</p>
<p className="text-sm text-slate-500">
Patiënten verschijnen hier wanneer er rapportages zijn gemaakt in de
afgelopen 24 uur
</p>
</div>
</div>
) : (
<RapportageWorkspace patients={patients} />
)}
</div>
);
}

View File

@@ -0,0 +1,273 @@
'use client';
/**
* LogForm Component - Compact Quick Entry
* Compacte inline entry met expandeerbaar volledig formulier
* Categorie pills + tijd inline, overdracht toggle prominent
*/
import { useState, useTransition, useRef, useEffect } from 'react';
import { format } from 'date-fns';
import {
Loader2,
Plus,
Pill,
Utensils,
User,
AlertTriangle,
FileText,
Clock,
ChevronDown,
CheckCircle2,
Send,
} from 'lucide-react';
import {
VERPLEEGKUNDIG_CATEGORIES,
CATEGORY_CONFIG,
type VerpleegkundigCategory,
} from '@/lib/types/report';
interface LogFormProps {
patientId: string;
onSuccess: () => void;
}
// Icon mapping
const CATEGORY_ICONS: Record<VerpleegkundigCategory, React.ComponentType<{ className?: string }>> = {
medicatie: Pill,
adl: Utensils,
gedrag: User,
incident: AlertTriangle,
observatie: FileText,
};
export function LogForm({ patientId, onSuccess }: LogFormProps) {
const [isExpanded, setIsExpanded] = useState(false);
const [category, setCategory] = useState<VerpleegkundigCategory>('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 textareaRef = useRef<HTMLTextAreaElement>(null);
// Auto-focus textarea when expanded
useEffect(() => {
if (isExpanded && textareaRef.current) {
textareaRef.current.focus();
}
}, [isExpanded]);
// Auto-expand when typing starts
const handleContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setContent(e.target.value);
if (!isExpanded && e.target.value.length > 0) {
setIsExpanded(true);
}
};
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);
startTransition(async () => {
try {
const response = await fetch('/api/reports', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
patient_id: patientId,
type: 'verpleegkundig',
content: content.trim(),
category,
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');
setIsExpanded(false);
onSuccess();
} catch (err) {
console.error('Failed to create report:', err);
setError(err instanceof Error ? err.message : 'Opslaan mislukt');
}
});
};
const charactersLeft = 500 - content.length;
const selectedConfig = CATEGORY_CONFIG[category];
const SelectedIcon = CATEGORY_ICONS[category];
return (
<form
onSubmit={handleSubmit}
className="bg-white rounded-lg border border-slate-200 overflow-hidden shadow-sm"
>
{/* Compact Header - Always visible */}
<div className="p-3">
{/* Category pills row */}
<div className="flex items-center gap-1.5 mb-3 overflow-x-auto pb-1 -mb-1">
{VERPLEEGKUNDIG_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={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-full text-xs font-medium transition-all whitespace-nowrap flex-shrink-0 ${
isSelected
? `${config.bgColor} ${config.textColor} ring-2 ring-offset-1 ring-current`
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
<Icon className="h-3.5 w-3.5" />
<span className="hidden sm:inline">{config.label}</span>
</button>
);
})}
</div>
{/* Input area with inline time and submit */}
<div className="flex items-start gap-2">
{/* Time input - compact */}
<div className="flex items-center gap-1 flex-shrink-0">
<Clock className="h-4 w-4 text-slate-400" />
<input
type="time"
value={time}
onChange={(e) => setTime(e.target.value)}
className="w-20 text-sm border-0 bg-slate-50 rounded px-2 py-1.5 focus:ring-2 focus:ring-teal-500 focus:bg-white"
/>
</div>
{/* Textarea - grows on focus/content */}
<div className="flex-1 relative">
<textarea
ref={textareaRef}
value={content}
onChange={handleContentChange}
onFocus={() => setIsExpanded(true)}
placeholder={`Nieuwe ${selectedConfig.label.toLowerCase()} notitie...`}
rows={isExpanded ? 3 : 1}
maxLength={500}
className={`w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:border-teal-500 focus:ring-2 focus:ring-teal-100 outline-none resize-none transition-all ${
isExpanded ? 'min-h-[80px]' : 'min-h-[38px]'
}`}
/>
{/* Character counter - only when expanded */}
{isExpanded && (
<div className="absolute bottom-2 right-2">
<span
className={`text-xs ${
charactersLeft < 50 ? 'text-amber-600 font-medium' : 'text-slate-400'
}`}
>
{charactersLeft}
</span>
</div>
)}
</div>
{/* Submit button - always visible */}
<button
type="submit"
disabled={isPending || !content.trim()}
className="flex-shrink-0 p-2.5 rounded-lg bg-teal-600 text-white hover:bg-teal-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
title="Opslaan"
>
{isPending ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<Send className="h-5 w-5" />
)}
</button>
</div>
</div>
{/* Expanded section - Handover toggle */}
{isExpanded && (
<div className="px-3 pb-3 pt-0 border-t border-slate-100 mt-2 pt-2">
<div className="flex items-center justify-between">
{/* Handover toggle - prominent */}
<button
type="button"
onClick={() => setIncludeInHandover(!includeInHandover)}
className={`inline-flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-all ${
includeInHandover
? 'bg-teal-100 text-teal-800 ring-2 ring-teal-500 ring-offset-1'
: 'bg-slate-100 text-slate-600 hover:bg-teal-50 hover:text-teal-700'
}`}
>
<CheckCircle2 className={`h-4 w-4 ${includeInHandover ? 'text-teal-600' : ''}`} />
<span>Overdracht</span>
{includeInHandover && (
<span className="text-xs bg-teal-600 text-white px-1.5 py-0.5 rounded">
Aan
</span>
)}
</button>
{/* Collapse button */}
<button
type="button"
onClick={() => {
if (!content.trim()) {
setIsExpanded(false);
}
}}
className="text-xs text-slate-400 hover:text-slate-600"
>
{content.trim() ? '' : 'Inklappen'}
</button>
</div>
{/* Error Message */}
{error && (
<div className="mt-2 p-2 bg-red-50 border border-red-200 rounded-lg">
<p className="text-sm text-red-700">{error}</p>
</div>
)}
</div>
)}
{/* Collapsed state helper text */}
{!isExpanded && !content && (
<div className="px-3 pb-2 -mt-1">
<p className="text-xs text-slate-400">
Klik op het tekstveld om te beginnen met typen
</p>
</div>
)}
</form>
);
}

View File

@@ -0,0 +1,591 @@
'use client';
/**
* LogList Component - Timeline UI
* Visuele timeline met notities gegroepeerd per dagdeel
* Quick toggle voor overdracht direct op kaart
*/
import { useState, useCallback, useTransition, useEffect } from 'react';
import { format, isToday, isYesterday } from 'date-fns';
import { nl } from 'date-fns/locale';
import {
Pill,
Utensils,
User,
AlertTriangle,
FileText,
Clock,
CheckCircle2,
Pencil,
Trash2,
X,
Check,
Loader2,
Sun,
Sunrise,
Sunset,
Moon,
} from 'lucide-react';
import type { Report } from '@/lib/types/overdracht';
import {
CATEGORY_CONFIG,
VERPLEEGKUNDIG_CATEGORIES,
getVerpleegkundigCategory,
type VerpleegkundigCategory,
} from '@/lib/types/report';
import { LogForm } from './log-form';
interface LogListProps {
patientId: string;
initialLogs: Report[];
startDate: string;
endDate: string;
/** If true, hides the LogForm (for when parent already shows it) */
hideForm?: boolean;
/** Callback when logs should be refreshed */
onRefresh?: () => void;
}
// Icon mapping
const CATEGORY_ICONS: Record<VerpleegkundigCategory, React.ComponentType<{ className?: string }>> = {
medicatie: Pill,
adl: Utensils,
gedrag: User,
incident: AlertTriangle,
observatie: FileText,
};
// Dagdeel bepalen op basis van uur
type DayPart = 'nacht' | 'ochtend' | 'middag' | 'avond';
function getDayPart(date: Date): DayPart {
const hour = date.getHours();
if (hour >= 0 && hour < 7) return 'nacht';
if (hour >= 7 && hour < 12) return 'ochtend';
if (hour >= 12 && hour < 17) return 'middag';
return 'avond';
}
const DAY_PART_CONFIG: Record<DayPart, { label: string; icon: React.ComponentType<{ className?: string }>; color: string }> = {
nacht: { label: 'Nacht', icon: Moon, color: 'text-indigo-600' },
ochtend: { label: 'Ochtend', icon: Sunrise, color: 'text-amber-600' },
middag: { label: 'Middag', icon: Sun, color: 'text-yellow-600' },
avond: { label: 'Avond', icon: Sunset, color: 'text-orange-600' },
};
// Groepeer logs per dag en dagdeel
interface GroupedLogs {
date: string;
dateLabel: string;
dayParts: {
part: DayPart;
logs: Report[];
}[];
}
function groupLogsByDayAndPart(logs: Report[]): GroupedLogs[] {
// Groepeer eerst per dag
const byDay = new Map<string, Report[]>();
logs.forEach(log => {
const date = new Date(log.created_at);
const dayKey = format(date, 'yyyy-MM-dd');
if (!byDay.has(dayKey)) {
byDay.set(dayKey, []);
}
byDay.get(dayKey)!.push(log);
});
// Sorteer dagen (nieuwste eerst)
const sortedDays = Array.from(byDay.entries()).sort((a, b) => b[0].localeCompare(a[0]));
return sortedDays.map(([dayKey, dayLogs]) => {
const date = new Date(dayKey);
// Bepaal daglabel
let dateLabel: string;
if (isToday(date)) {
dateLabel = 'Vandaag';
} else if (isYesterday(date)) {
dateLabel = 'Gisteren';
} else {
dateLabel = format(date, 'EEEE d MMMM', { locale: nl });
}
// Groepeer per dagdeel
const byPart = new Map<DayPart, Report[]>();
dayLogs.forEach(log => {
const logDate = new Date(log.created_at);
const part = getDayPart(logDate);
if (!byPart.has(part)) {
byPart.set(part, []);
}
byPart.get(part)!.push(log);
});
// Sorteer dagdelen in logische volgorde (nieuwste eerst = avond, middag, ochtend, nacht)
const partOrder: DayPart[] = ['avond', 'middag', 'ochtend', 'nacht'];
const dayParts = partOrder
.filter(part => byPart.has(part))
.map(part => ({
part,
logs: byPart.get(part)!.sort((a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
),
}));
return { date: dayKey, dateLabel, dayParts };
});
}
export function LogList({ patientId, initialLogs, startDate, endDate, hideForm = false, onRefresh }: LogListProps) {
const [logs, setLogs] = useState<Report[]>(initialLogs);
// Sync with initialLogs when they change (e.g., from parent component)
useEffect(() => {
setLogs(initialLogs);
}, [initialLogs]);
// Refresh logs from API
const refreshLogs = useCallback(async () => {
// If parent handles refresh, call that instead
if (onRefresh) {
onRefresh();
return;
}
try {
const response = await fetch(
`/api/reports?patientId=${patientId}&type=verpleegkundig&startDate=${startDate}&endDate=${endDate}`
);
if (response.ok) {
const data = await response.json();
setLogs(data.reports);
}
} catch (error) {
console.error('Failed to refresh logs:', error);
}
}, [patientId, startDate, endDate, onRefresh]);
// Group logs by category for summary
const logsByCategory = logs.reduce(
(acc, log) => {
const category = getVerpleegkundigCategory(log.structured_data) || 'observatie';
acc[category] = (acc[category] || 0) + 1;
return acc;
},
{} as Record<string, number>
);
const markedForHandover = logs.filter((l) => l.include_in_handover).length;
const groupedLogs = groupLogsByDayAndPart(logs);
return (
<div className="space-y-6">
{/* Quick Entry Form - only show if not hidden */}
{!hideForm && <LogForm patientId={patientId} onSuccess={refreshLogs} />}
{/* Summary Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="bg-white rounded-lg border border-slate-200 p-3">
<div className="text-2xl font-bold text-slate-900">{logs.length}</div>
<div className="text-xs text-slate-500">
{startDate === endDate ? 'Notities vandaag' : 'Totaal notities'}
</div>
</div>
<div className="bg-white rounded-lg border border-slate-200 p-3">
<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-xs text-slate-500">Voor overdracht</div>
</div>
{logsByCategory['incident'] > 0 && (
<div className="bg-red-50 rounded-lg border border-red-200 p-3">
<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-xs text-red-600">Incidenten</div>
</div>
)}
</div>
{/* Timeline */}
{logs.length === 0 ? (
<div className="bg-white rounded-lg border border-slate-200 p-8 text-center">
<FileText className="h-12 w-12 text-slate-300 mx-auto mb-3" />
<p className="text-slate-600 mb-1">
{startDate === endDate
? 'Nog geen notities op deze dag'
: 'Geen notities in deze periode'}
</p>
<p className="text-sm text-slate-500">
Voeg een notitie toe via het formulier hierboven
</p>
</div>
) : (
<div className="space-y-6">
{groupedLogs.map((dayGroup) => (
<div key={dayGroup.date} className="bg-white rounded-lg border border-slate-200 overflow-hidden">
{/* Day Header */}
<div className="px-4 py-3 bg-slate-50 border-b border-slate-200">
<h3 className="font-semibold text-slate-900 capitalize">
{dayGroup.dateLabel}
</h3>
</div>
{/* Day Parts with Timeline */}
<div className="relative">
{dayGroup.dayParts.map((partGroup, partIndex) => {
const PartIcon = DAY_PART_CONFIG[partGroup.part].icon;
return (
<div key={partGroup.part} className="relative">
{/* Day Part Header */}
<div className="flex items-center gap-3 px-4 py-2 bg-slate-50/50 border-b border-slate-100">
<PartIcon className={`h-4 w-4 ${DAY_PART_CONFIG[partGroup.part].color}`} />
<span className="text-sm font-medium text-slate-600">
{DAY_PART_CONFIG[partGroup.part].label}
</span>
<span className="text-xs text-slate-400">
{partGroup.logs.length} {partGroup.logs.length === 1 ? 'notitie' : 'notities'}
</span>
</div>
{/* Timeline with logs */}
<div className="relative pl-8">
{/* Vertical timeline line */}
<div className="absolute left-6 top-0 bottom-0 w-0.5 bg-slate-200" />
{partGroup.logs.map((log, logIndex) => (
<TimelineCard
key={log.id}
log={log}
onUpdate={refreshLogs}
isLast={logIndex === partGroup.logs.length - 1 && partIndex === dayGroup.dayParts.length - 1}
/>
))}
</div>
</div>
);
})}
</div>
</div>
))}
</div>
)}
</div>
);
}
interface TimelineCardProps {
log: Report;
onUpdate: () => void;
isLast?: boolean;
}
function TimelineCard({ log, onUpdate, isLast = false }: TimelineCardProps) {
const [isEditing, setIsEditing] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [editContent, setEditContent] = useState(log.content);
const category = getVerpleegkundigCategory(log.structured_data) || 'observatie';
const [editCategory, setEditCategory] = useState<VerpleegkundigCategory>(category);
const [editHandover, setEditHandover] = useState(log.include_in_handover ?? false);
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const config = CATEGORY_CONFIG[category];
const Icon = CATEGORY_ICONS[category] || FileText;
const time = format(new Date(log.created_at), 'HH:mm', { locale: nl });
// Toggle handover without entering edit mode
const toggleHandover = () => {
startTransition(async () => {
try {
const response = await fetch(`/api/reports/${log.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
include_in_handover: !log.include_in_handover,
}),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Wijzigen mislukt');
}
onUpdate();
} catch (err) {
console.error('Toggle handover failed:', err);
}
});
};
const handleSave = () => {
if (!editContent.trim()) {
setError('Notitie mag niet leeg zijn');
return;
}
setError(null);
startTransition(async () => {
try {
const response = await fetch(`/api/reports/${log.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: editContent.trim(),
structured_data: { 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/reports/${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(category);
setEditHandover(log.include_in_handover ?? false);
setError(null);
};
// Delete confirmation
if (showDeleteConfirm) {
return (
<div className={`relative py-3 pr-4 ${!isLast ? 'border-b border-slate-100' : ''}`}>
{/* Timeline node */}
<div className="absolute -left-2 top-5 w-4 h-4 rounded-full bg-red-500 border-2 border-white shadow-sm flex items-center justify-center">
<Trash2 className="h-2 w-2 text-white" />
</div>
<div className="ml-4 p-3 bg-red-50 rounded-lg border border-red-200">
<p className="text-sm font-medium text-red-900 mb-2">
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-xs 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-xs font-medium text-red-700 hover:text-red-900"
>
Annuleren
</button>
</div>
</div>
</div>
);
}
// Edit mode
if (isEditing) {
return (
<div className={`relative py-3 pr-4 ${!isLast ? 'border-b border-slate-100' : ''}`}>
{/* Timeline node */}
<div className={`absolute -left-2 top-5 w-4 h-4 rounded-full ${config.bgColor} border-2 border-white shadow-sm flex items-center justify-center`}>
<Icon className={`h-2.5 w-2.5 ${config.textColor}`} />
</div>
<div className="ml-4 p-3 bg-amber-50 rounded-lg border border-amber-200">
<div className="space-y-3">
{/* Category selector */}
<div className="flex flex-wrap gap-1">
{VERPLEEGKUNDIG_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-xs 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-xs font-medium text-slate-600 hover:text-slate-900"
>
<X className="h-3 w-3" />
Annuleren
</button>
</div>
</div>
</div>
</div>
);
}
// Normal view
return (
<div className={`relative py-3 pr-4 group ${!isLast ? 'border-b border-slate-100' : ''}`}>
{/* Timeline node with category icon */}
<div className={`absolute -left-2 top-5 w-4 h-4 rounded-full ${config.bgColor} border-2 border-white shadow-sm flex items-center justify-center transition-transform group-hover:scale-110`}>
<Icon className={`h-2.5 w-2.5 ${config.textColor}`} />
</div>
{/* Card content */}
<div className="ml-4 hover:bg-slate-50 rounded-lg p-2 -m-2 transition-colors">
<div className="flex items-start gap-3">
{/* Time */}
<div className="flex-shrink-0 w-12 text-right">
<span className="text-sm font-medium text-slate-500">{time}</span>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
{/* Header with badges */}
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span
className={`text-xs font-medium px-2 py-0.5 rounded-full ${config.bgColor} ${config.textColor}`}
>
{config.label}
</span>
{/* Overdracht toggle button */}
<button
onClick={toggleHandover}
disabled={isPending}
className={`inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full transition-all ${
log.include_in_handover
? 'bg-teal-100 text-teal-700 hover:bg-teal-200'
: 'bg-slate-100 text-slate-500 hover:bg-teal-50 hover:text-teal-600'
}`}
title={log.include_in_handover ? 'Verwijder uit overdracht' : 'Voeg toe aan overdracht'}
>
<CheckCircle2 className={`h-3 w-3 ${isPending ? 'animate-pulse' : ''}`} />
<span className="hidden sm:inline">Overdracht</span>
</button>
</div>
{/* Note content */}
<p className="text-sm text-slate-700 whitespace-pre-wrap">
{log.content}
</p>
</div>
{/* Action buttons */}
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
<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-3.5 w-3.5" />
</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-3.5 w-3.5" />
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,58 @@
'use client';
/**
* PeriodSelector Component
* Eenvoudige dropdown voor het selecteren van een periode voor de dagregistratie
*/
import { useRouter, useSearchParams } from 'next/navigation';
import { Calendar } from 'lucide-react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { PERIOD_OPTIONS, type PeriodValue } from '../../lib/period-utils';
interface PeriodSelectorProps {
patientId: string;
}
export function PeriodSelector({ patientId }: PeriodSelectorProps) {
const router = useRouter();
const searchParams = useSearchParams();
const currentPeriod = (searchParams.get('periode') as PeriodValue) || 'today';
const handlePeriodChange = (value: PeriodValue) => {
const params = new URLSearchParams(searchParams.toString());
if (value === 'today') {
params.delete('periode');
} else {
params.set('periode', value);
}
const queryString = params.toString();
router.push(
`/epd/verpleegrapportage/rapportage/${patientId}${queryString ? `?${queryString}` : ''}`
);
};
return (
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-slate-500" />
<Select value={currentPeriod} onValueChange={handlePeriodChange}>
<SelectTrigger className="w-[180px] h-9 bg-white border-slate-200">
<SelectValue placeholder="Selecteer periode" />
</SelectTrigger>
<SelectContent>
{PERIOD_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}

View File

@@ -0,0 +1,172 @@
/**
* Rapportage Per Patiënt Page
* Route /epd/verpleegrapportage/rapportage/[patientId]
* Met periode selector voor terugkijken naar eerdere dagen
*/
import { createClient } from '@/lib/auth/server';
import { notFound } from 'next/navigation';
import { LogList } from './components/log-list';
import { PeriodSelector } from './components/period-selector';
import { getPeriodDateRange, type PeriodValue } from '../lib/period-utils';
import { ArrowLeft, PenLine, ClipboardList } from 'lucide-react';
import Link from 'next/link';
import type { Report } from '@/lib/types/overdracht';
interface PageProps {
params: Promise<{ patientId: string }>;
searchParams: Promise<{ periode?: string }>;
}
async function getPatientWithLogs(patientId: string, period: PeriodValue) {
const supabase = await createClient();
const { startDate, endDate } = getPeriodDateRange(period);
const [patientResult, logsResult] = await Promise.all([
supabase
.from('patients')
.select('id, name_given, name_family, name_prefix, birth_date, gender')
.eq('id', patientId)
.single(),
// Nu reports met type='verpleegkundig' i.p.v. nursing_logs
supabase
.from('reports')
.select('id, type, content, created_at, created_by, structured_data, include_in_handover, shift_date')
.eq('patient_id', patientId)
.eq('type', 'verpleegkundig')
.gte('shift_date', startDate)
.lte('shift_date', endDate)
.is('deleted_at', null)
.order('created_at', { ascending: false }),
]);
if (patientResult.error || !patientResult.data) {
return null;
}
return {
patient: patientResult.data,
logs: (logsResult.data || []) as Report[],
startDate,
endDate,
period,
};
}
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 RapportagePatientPage({ params, searchParams }: PageProps) {
const { patientId } = await params;
const { periode } = await searchParams;
// Validate period parameter
const validPeriods: PeriodValue[] = ['today', 'yesterday', '3days', '7days'];
const period: PeriodValue = validPeriods.includes(periode as PeriodValue)
? (periode as PeriodValue)
: 'today';
const data = await getPatientWithLogs(patientId, period);
if (!data) {
notFound();
}
const { patient, logs, startDate, endDate } = data;
const patientName = formatPatientName(
patient.name_given,
patient.name_family,
patient.name_prefix
);
const age = calculateAge(patient.birth_date);
// Format date range for display
const formatDate = (dateStr: string) =>
new Date(dateStr).toLocaleDateString('nl-NL', {
weekday: 'short',
day: 'numeric',
month: 'short',
});
const displayDate = startDate === endDate
? new Date(startDate).toLocaleDateString('nl-NL', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
})
: `${formatDate(startDate)} - ${formatDate(endDate)}`;
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 justify-between 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>
<Link
href={`/epd/verpleegrapportage?patient=${patientId}`}
className="flex items-center gap-2 text-sm text-violet-600 hover:text-violet-700 font-medium transition-colors"
>
<ClipboardList className="h-4 w-4" />
Naar overdracht
</Link>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-amber-100 rounded-full flex items-center justify-center">
<PenLine className="h-6 w-6 text-amber-600" />
</div>
<div>
<h1 className="text-xl font-semibold text-slate-900">
Rapportage
</h1>
<p className="text-sm text-slate-600">
{patientName} ({age} jaar) {displayDate}
</p>
</div>
</div>
<PeriodSelector patientId={patientId} />
</div>
</div>
</div>
{/* Content */}
<div className="max-w-4xl mx-auto px-4 py-6">
<LogList
patientId={patientId}
initialLogs={logs}
startDate={startDate}
endDate={endDate}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,72 @@
'use client';
/**
* PatientSelector Component
* Dropdown voor het selecteren van een patiënt met notitie count badges
*/
import { User } from 'lucide-react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { PatientOverzicht } from '@/lib/types/overdracht';
interface PatientSelectorProps {
patients: PatientOverzicht[];
selectedId: string | null;
onSelect: (patientId: string) => void;
logCounts?: Record<string, number>;
}
function formatPatientName(patient: PatientOverzicht): string {
const given = patient.name_given.join(' ');
return `${given} ${patient.name_family}`;
}
export function PatientSelector({
patients,
selectedId,
onSelect,
logCounts = {},
}: PatientSelectorProps) {
if (patients.length === 0) {
return (
<div className="flex items-center gap-2 text-slate-500 text-sm">
<User className="h-4 w-4" />
<span>Geen patiënten beschikbaar</span>
</div>
);
}
return (
<div className="flex items-center gap-3">
<label className="text-sm font-medium text-slate-700">Patiënt:</label>
<Select value={selectedId || ''} onValueChange={onSelect}>
<SelectTrigger className="w-[280px] bg-white">
<SelectValue placeholder="Selecteer een patiënt" />
</SelectTrigger>
<SelectContent>
{patients.map((patient) => {
const count = logCounts[patient.id] || 0;
return (
<SelectItem key={patient.id} value={patient.id}>
<div className="flex items-center justify-between w-full gap-3">
<span>{formatPatientName(patient)}</span>
{count > 0 && (
<span className="text-xs bg-slate-100 text-slate-600 px-1.5 py-0.5 rounded">
{count}
</span>
)}
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
);
}

View File

@@ -0,0 +1,612 @@
'use client';
/**
* RapportageWorkspace Component
*
* Layout:
* - Links: Patiënten sidebar
* - Rechts: Geselecteerde patiënt met:
* 1. Risico alerts (indien aanwezig)
* 2. Compact invoerformulier
* 3. Timeline met notities
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import {
AlertTriangle,
CheckCircle2,
FileText,
Pill,
Utensils,
User,
Send,
Loader2,
Sun,
Sunrise,
Sunset,
Moon,
Pencil,
Trash2,
} from 'lucide-react';
import { format, isToday, isYesterday } from 'date-fns';
import { nl } from 'date-fns/locale';
import type { PatientOverzicht, Report } from '@/lib/types/overdracht';
import {
VERPLEEGKUNDIG_CATEGORIES,
CATEGORY_CONFIG,
getVerpleegkundigCategory,
type VerpleegkundigCategory,
} from '@/lib/types/report';
const CATEGORY_ICONS: Record<VerpleegkundigCategory, React.ComponentType<{ className?: string }>> = {
medicatie: Pill,
adl: Utensils,
gedrag: User,
incident: AlertTriangle,
observatie: FileText,
};
type DayPart = 'nacht' | 'ochtend' | 'middag' | 'avond';
const DAY_PART_CONFIG: Record<DayPart, { label: string; icon: React.ComponentType<{ className?: string }>; color: string }> = {
nacht: { label: 'Nacht', icon: Moon, color: 'text-indigo-500' },
ochtend: { label: 'Ochtend', icon: Sunrise, color: 'text-amber-500' },
middag: { label: 'Middag', icon: Sun, color: 'text-yellow-500' },
avond: { label: 'Avond', icon: Sunset, color: 'text-orange-500' },
};
function getDayPart(date: Date): DayPart {
const hour = date.getHours();
if (hour >= 0 && hour < 7) return 'nacht';
if (hour >= 7 && hour < 12) return 'ochtend';
if (hour >= 12 && hour < 17) return 'middag';
return 'avond';
}
function getTodayDateRange() {
const today = new Date();
const todayStr = today.toISOString().split('T')[0];
return { startDate: todayStr, endDate: todayStr };
}
function groupLogsByDayAndPart(logs: Report[]) {
const byDay = new Map<string, Report[]>();
logs.forEach(log => {
const dayKey = format(new Date(log.created_at), 'yyyy-MM-dd');
if (!byDay.has(dayKey)) byDay.set(dayKey, []);
byDay.get(dayKey)!.push(log);
});
return Array.from(byDay.entries())
.sort((a, b) => b[0].localeCompare(a[0]))
.map(([dayKey, dayLogs]) => {
const date = new Date(dayKey);
const dateLabel = isToday(date) ? 'Vandaag' : isYesterday(date) ? 'Gisteren' : format(date, 'EEEE d MMM', { locale: nl });
const byPart = new Map<DayPart, Report[]>();
dayLogs.forEach(log => {
const part = getDayPart(new Date(log.created_at));
if (!byPart.has(part)) byPart.set(part, []);
byPart.get(part)!.push(log);
});
const dayParts = (['avond', 'middag', 'ochtend', 'nacht'] as DayPart[])
.filter(part => byPart.has(part))
.map(part => ({
part,
logs: byPart.get(part)!.sort((a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
),
}));
return { date: dayKey, dateLabel, dayParts };
});
}
interface RapportageWorkspaceProps {
patients: PatientOverzicht[];
}
export function RapportageWorkspace({ patients }: RapportageWorkspaceProps) {
const [selectedPatientId, setSelectedPatientId] = useState<string | null>(patients[0]?.id || null);
const [logs, setLogs] = useState<Report[]>([]);
const [allLogs, setAllLogs] = useState<Record<string, Report[]>>({});
const [isLoading, setIsLoading] = useState(true);
const { startDate, endDate } = getTodayDateRange();
const selectedPatient = patients.find(p => p.id === selectedPatientId);
// Fetch logs for all patients
const fetchAllPatientLogs = useCallback(async () => {
setIsLoading(true);
const logsMap: Record<string, Report[]> = {};
await Promise.all(
patients.map(async (patient) => {
try {
const response = await fetch(
`/api/reports?patientId=${patient.id}&type=verpleegkundig&startDate=${startDate}&endDate=${endDate}`
);
if (response.ok) {
const data = await response.json();
logsMap[patient.id] = data.reports || [];
}
} catch (error) {
console.error(`Failed to fetch logs for ${patient.id}:`, error);
logsMap[patient.id] = [];
}
})
);
setAllLogs(logsMap);
if (selectedPatientId && logsMap[selectedPatientId]) {
setLogs(logsMap[selectedPatientId]);
}
setIsLoading(false);
}, [patients, startDate, endDate, selectedPatientId]);
useEffect(() => {
fetchAllPatientLogs();
}, [fetchAllPatientLogs]);
useEffect(() => {
if (selectedPatientId && allLogs[selectedPatientId] !== undefined) {
setLogs(allLogs[selectedPatientId]);
}
}, [selectedPatientId, allLogs]);
const handleRefresh = async () => {
await fetchAllPatientLogs();
};
// Calculate counts
const logCounts: Record<string, number> = {};
const handoverCounts: Record<string, number> = {};
for (const [patientId, patientLogs] of Object.entries(allLogs)) {
logCounts[patientId] = patientLogs.length;
handoverCounts[patientId] = patientLogs.filter(l => l.include_in_handover).length;
}
const markedForHandover = logs.filter(l => l.include_in_handover).length;
const groupedLogs = groupLogsByDayAndPart(logs);
return (
<div className="h-full flex bg-slate-50">
{/* Sidebar - Patiënten */}
<aside className="w-80 border-r border-slate-200 bg-white flex-shrink-0 overflow-hidden flex flex-col">
<div className="p-4 border-b border-slate-200">
<h2 className="font-semibold text-slate-900">Ronde overzicht</h2>
</div>
<div className="flex-1 overflow-y-auto divide-y divide-slate-100">
{patients.map(patient => {
const isSelected = patient.id === selectedPatientId;
const count = logCounts[patient.id] || 0;
const handover = handoverCounts[patient.id] || 0;
const hasRisks = patient.alerts.high_risk_count > 0;
const name = `${patient.name_given[0]} ${patient.name_family}`;
return (
<button
key={patient.id}
onClick={() => setSelectedPatientId(patient.id)}
className={`w-full px-4 py-3 text-left transition-colors ${
isSelected
? 'bg-teal-50'
: 'hover:bg-slate-50'
}`}
>
<div className="flex items-center justify-between">
<span className={`font-medium truncate ${isSelected ? 'text-teal-900' : 'text-slate-900'}`}>
{name}
</span>
<div className="flex items-center gap-1 flex-shrink-0">
{hasRisks && (
<span
className="flex items-center gap-0.5 px-1.5 py-0.5 bg-red-100 text-red-700 rounded text-xs font-medium"
title={`${patient.alerts.high_risk_count} hoog risico${patient.alerts.high_risk_count > 1 ? "'s" : ''}`}
>
<AlertTriangle className="h-3 w-3" />
{patient.alerts.high_risk_count}
</span>
)}
{handover > 0 && (
<span
className="inline-flex items-center gap-0.5 text-xs bg-teal-100 text-teal-700 px-1.5 py-0.5 rounded"
title={`${handover} voor overdracht`}
>
<CheckCircle2 className="h-3 w-3" />
{handover}
</span>
)}
</div>
</div>
</button>
);
})}
</div>
</aside>
{/* Main content */}
<main className="flex-1 overflow-y-auto p-6 space-y-4">
{/* Risico alerts */}
{selectedPatient && selectedPatient.alerts.high_risk_count > 0 && (
<div className="bg-red-50 border border-red-200 rounded-lg p-3 flex items-center gap-3">
<AlertTriangle className="h-5 w-5 text-red-600 flex-shrink-0" />
<div>
<span className="font-medium text-red-900">
{selectedPatient.alerts.high_risk_count} hoog risico
</span>
<span className="text-red-700 text-sm ml-2">
Let op verhoogde aandachtspunten voor deze cliënt
</span>
</div>
</div>
)}
{/* Invoerformulier */}
{selectedPatientId && (
<QuickEntryForm
patientId={selectedPatientId}
onSuccess={handleRefresh}
/>
)}
{/* Stats row - alleen tonen als er data is */}
{logs.length > 0 && (
<div className="flex items-center gap-4 text-sm">
<span className="text-slate-600">
<span className="font-semibold text-slate-900">{logs.length}</span> notities
</span>
{markedForHandover > 0 && (
<span className="flex items-center gap-1 text-teal-700">
<CheckCircle2 className="h-4 w-4" />
<span className="font-semibold">{markedForHandover}</span> overdracht
</span>
)}
</div>
)}
{/* Timeline */}
{isLoading ? (
<div className="bg-white rounded-lg border border-slate-200 p-8 text-center">
<Loader2 className="h-8 w-8 text-slate-400 animate-spin mx-auto" />
<p className="text-sm text-slate-500 mt-2">Laden...</p>
</div>
) : logs.length === 0 ? (
<div className="bg-white rounded-lg border border-slate-200 p-8 text-center">
<FileText className="h-10 w-10 text-slate-300 mx-auto mb-2" />
<p className="text-slate-600">Nog geen notities</p>
<p className="text-sm text-slate-500">Voeg een notitie toe via het formulier hierboven</p>
</div>
) : (
<div className="space-y-4">
{groupedLogs.map(dayGroup => (
<div key={dayGroup.date} className="bg-white rounded-lg border border-slate-200 overflow-hidden">
<div className="px-4 py-2 bg-slate-50 border-b border-slate-100">
<span className="font-medium text-slate-700 capitalize text-sm">{dayGroup.dateLabel}</span>
</div>
{dayGroup.dayParts.map(partGroup => {
const PartIcon = DAY_PART_CONFIG[partGroup.part].icon;
return (
<div key={partGroup.part}>
<div className="flex items-center gap-2 px-4 py-1.5 bg-slate-50/50 border-b border-slate-50">
<PartIcon className={`h-3.5 w-3.5 ${DAY_PART_CONFIG[partGroup.part].color}`} />
<span className="text-xs font-medium text-slate-500">{DAY_PART_CONFIG[partGroup.part].label}</span>
</div>
<div className="relative pl-8">
<div className="absolute left-5 top-0 bottom-0 w-0.5 bg-slate-100" />
{partGroup.logs.map((log, idx) => (
<TimelineItem
key={log.id}
log={log}
onRefresh={handleRefresh}
isLast={idx === partGroup.logs.length - 1}
/>
))}
</div>
</div>
);
})}
</div>
))}
</div>
)}
</main>
</div>
);
}
// Compact Quick Entry Form
function QuickEntryForm({ patientId, onSuccess }: { patientId: string; onSuccess: () => void }) {
const [category, setCategory] = useState<VerpleegkundigCategory>('observatie');
const [content, setContent] = useState('');
const [includeInHandover, setIncludeInHandover] = useState(false);
const [isPending, setIsPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Auto-resize textarea
useEffect(() => {
const textarea = textareaRef.current;
if (textarea) {
textarea.style.height = 'auto';
textarea.style.height = `${Math.max(56, Math.min(textarea.scrollHeight, 200))}px`;
}
}, [content]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!content.trim()) return;
setIsPending(true);
setError(null);
try {
const response = await fetch('/api/reports', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
patient_id: patientId,
type: 'verpleegkundig',
content: content.trim(),
category,
include_in_handover: includeInHandover,
}),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Opslaan mislukt');
}
setContent('');
setIncludeInHandover(false);
setCategory('observatie');
onSuccess();
} catch (err) {
setError(err instanceof Error ? err.message : 'Opslaan mislukt');
} finally {
setIsPending(false);
}
};
const selectedConfig = CATEGORY_CONFIG[category];
return (
<form onSubmit={handleSubmit} className="bg-white rounded-lg border border-slate-200 p-3">
{/* Category pills */}
<div className="flex items-center gap-1 mb-2 overflow-x-auto pb-1">
{VERPLEEGKUNDIG_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={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium transition-colors whitespace-nowrap ${
isSelected
? `${config.bgColor} ${config.textColor}`
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
<Icon className="h-3 w-3" />
{config.label}
</button>
);
})}
</div>
{/* Input row */}
<div className="flex items-start gap-2">
<textarea
ref={textareaRef}
value={content}
onChange={e => setContent(e.target.value)}
placeholder={`${selectedConfig.label} notitie...`}
maxLength={500}
className="flex-1 border border-slate-200 rounded-lg px-3 py-2 text-sm focus:border-teal-500 focus:ring-1 focus:ring-teal-500 outline-none resize-none min-h-[56px]"
/>
<button
type="submit"
disabled={isPending || !content.trim()}
className="p-2.5 rounded-lg bg-teal-600 text-white hover:bg-teal-700 disabled:opacity-40 transition-colors"
>
{isPending ? <Loader2 className="h-5 w-5 animate-spin" /> : <Send className="h-5 w-5" />}
</button>
</div>
{/* Bottom row */}
<div className="flex items-center justify-between mt-2">
<button
type="button"
onClick={() => setIncludeInHandover(!includeInHandover)}
className={`inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium transition-colors ${
includeInHandover
? 'bg-teal-100 text-teal-800'
: 'bg-slate-100 text-slate-600 hover:bg-teal-50 hover:text-teal-700'
}`}
>
<CheckCircle2 className="h-3.5 w-3.5" />
Overdracht
</button>
<span className="text-xs text-slate-400">{500 - content.length}</span>
</div>
{error && (
<p className="text-xs text-red-600 mt-2">{error}</p>
)}
</form>
);
}
// Timeline Item
function TimelineItem({ log, onRefresh, isLast }: { log: Report; onRefresh: () => void; isLast: boolean }) {
const [isEditing, setIsEditing] = useState(false);
const [showDelete, setShowDelete] = useState(false);
const [editContent, setEditContent] = useState(log.content);
const category = getVerpleegkundigCategory(log.structured_data) || 'observatie';
const [editCategory, setEditCategory] = useState<VerpleegkundigCategory>(category);
const [editHandover, setEditHandover] = useState(log.include_in_handover ?? false);
const [isPending, setIsPending] = useState(false);
const config = CATEGORY_CONFIG[category];
const Icon = CATEGORY_ICONS[category];
const time = format(new Date(log.created_at), 'HH:mm');
const toggleHandover = async () => {
setIsPending(true);
try {
await fetch(`/api/reports/${log.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ include_in_handover: !log.include_in_handover }),
});
onRefresh();
} finally {
setIsPending(false);
}
};
const handleSave = async () => {
if (!editContent.trim()) return;
setIsPending(true);
try {
await fetch(`/api/reports/${log.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: editContent.trim(),
structured_data: { category: editCategory },
include_in_handover: editHandover,
}),
});
setIsEditing(false);
onRefresh();
} finally {
setIsPending(false);
}
};
const handleDelete = async () => {
setIsPending(true);
try {
await fetch(`/api/reports/${log.id}`, { method: 'DELETE' });
onRefresh();
} finally {
setIsPending(false);
}
};
if (showDelete) {
return (
<div className={`py-2 pr-4 ${!isLast ? 'border-b border-slate-50' : ''}`}>
<div className="ml-3 p-2 bg-red-50 rounded border border-red-200">
<p className="text-sm text-red-900 mb-2">Verwijderen?</p>
<div className="flex gap-2">
<button onClick={handleDelete} disabled={isPending} className="text-xs px-2 py-1 bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50">
{isPending ? 'Bezig...' : 'Ja, verwijder'}
</button>
<button onClick={() => setShowDelete(false)} className="text-xs px-2 py-1 text-red-700 hover:text-red-900">
Annuleren
</button>
</div>
</div>
</div>
);
}
if (isEditing) {
return (
<div className={`py-2 pr-4 ${!isLast ? 'border-b border-slate-50' : ''}`}>
<div className="ml-3 p-2 bg-amber-50 rounded border border-amber-200 space-y-2">
<div className="flex flex-wrap gap-1">
{VERPLEEGKUNDIG_CATEGORIES.map(cat => {
const catConfig = CATEGORY_CONFIG[cat];
return (
<button
key={cat}
type="button"
onClick={() => setEditCategory(cat)}
className={`text-xs px-2 py-0.5 rounded-full ${
editCategory === cat ? `${catConfig.bgColor} ${catConfig.textColor}` : 'bg-slate-100 text-slate-600'
}`}
>
{catConfig.label}
</button>
);
})}
</div>
<textarea
value={editContent}
onChange={e => setEditContent(e.target.value)}
rows={2}
className="w-full text-sm border border-amber-200 rounded p-2 focus:border-amber-400 outline-none resize-none"
/>
<div className="flex items-center justify-between">
<label className="flex items-center gap-1.5 text-xs text-slate-700 cursor-pointer">
<input type="checkbox" checked={editHandover} onChange={e => setEditHandover(e.target.checked)} className="rounded" />
Overdracht
</label>
<div className="flex gap-1">
<button onClick={handleSave} disabled={isPending} className="text-xs px-2 py-1 bg-teal-600 text-white rounded hover:bg-teal-700 disabled:opacity-50">
{isPending ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Opslaan'}
</button>
<button onClick={() => { setIsEditing(false); setEditContent(log.content); setEditCategory(category); setEditHandover(log.include_in_handover ?? false); }} className="text-xs px-2 py-1 text-slate-600">
Annuleren
</button>
</div>
</div>
</div>
</div>
);
}
return (
<div className={`relative py-2 pr-4 group ${!isLast ? 'border-b border-slate-50' : ''}`}>
{/* Timeline node */}
<div className={`absolute -left-2.5 top-3 w-3 h-3 rounded-full ${config.bgColor} border-2 border-white shadow-sm`} />
<div className="ml-3 flex items-start gap-2">
{/* Time */}
<span className="text-xs font-medium text-slate-400 w-10 pt-0.5">{time}</span>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 mb-0.5">
<span className={`text-xs font-medium px-1.5 py-0.5 rounded ${config.bgColor} ${config.textColor}`}>
{config.label}
</span>
<button
onClick={toggleHandover}
disabled={isPending}
className={`text-xs px-1.5 py-0.5 rounded transition-colors ${
log.include_in_handover
? 'bg-teal-100 text-teal-700'
: 'bg-slate-100 text-slate-400 hover:bg-teal-50 hover:text-teal-600'
}`}
>
<CheckCircle2 className={`h-3 w-3 inline ${isPending ? 'animate-pulse' : ''}`} />
</button>
</div>
<p className="text-sm text-slate-700">{log.content}</p>
</div>
{/* Actions */}
<div className="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={() => setIsEditing(true)} className="p-1 text-slate-400 hover:text-slate-600 rounded">
<Pencil className="h-3.5 w-3.5" />
</button>
<button onClick={() => setShowDelete(true)} className="p-1 text-slate-400 hover:text-red-600 rounded">
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,98 @@
'use client';
/**
* RondeOverview Component
* Overzicht van alle patiënten in de ronde met notitie counts
*/
import { User, CheckCircle2 } from 'lucide-react';
import type { PatientOverzicht } from '@/lib/types/overdracht';
interface RondeOverviewProps {
patients: PatientOverzicht[];
selectedId: string | null;
onSelect: (patientId: string) => void;
logCounts: Record<string, number>;
handoverCounts: Record<string, number>;
}
function formatPatientName(patient: PatientOverzicht): string {
const given = patient.name_given.join(' ');
return `${given} ${patient.name_family}`;
}
export function RondeOverview({
patients,
selectedId,
onSelect,
logCounts,
handoverCounts,
}: RondeOverviewProps) {
if (patients.length === 0) {
return null;
}
return (
<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">
<h3 className="font-semibold text-slate-900 text-sm">Ronde overzicht</h3>
</div>
<div className="divide-y divide-slate-100">
{patients.map((patient) => {
const isSelected = patient.id === selectedId;
const logs = logCounts[patient.id] || 0;
const handovers = handoverCounts[patient.id] || 0;
return (
<button
key={patient.id}
onClick={() => onSelect(patient.id)}
className={`w-full px-4 py-3 flex items-center justify-between text-left transition-colors ${
isSelected
? 'bg-teal-50 border-l-4 border-l-teal-500'
: 'hover:bg-slate-50 border-l-4 border-l-transparent'
}`}
>
<div className="flex items-center gap-3">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center ${
isSelected ? 'bg-teal-100' : 'bg-slate-100'
}`}
>
<User
className={`h-4 w-4 ${
isSelected ? 'text-teal-600' : 'text-slate-500'
}`}
/>
</div>
<span
className={`text-sm ${
isSelected ? 'font-medium text-teal-900' : 'text-slate-700'
}`}
>
{formatPatientName(patient)}
</span>
</div>
<div className="flex items-center gap-2">
{logs > 0 ? (
<span className="text-xs text-slate-500">
{logs} {logs === 1 ? 'notitie' : 'notities'}
</span>
) : (
<span className="text-xs text-slate-400"></span>
)}
{handovers > 0 && (
<span className="flex items-center gap-1 text-xs text-teal-600">
<CheckCircle2 className="h-3 w-3" />
{handovers}
</span>
)}
</div>
</button>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,65 @@
/**
* Period utilities voor Zorgnotities
* Gedeelde functies voor server en client components
*/
export type PeriodValue = 'today' | 'yesterday' | '3days' | '7days';
export interface PeriodOption {
value: PeriodValue;
label: string;
}
export const PERIOD_OPTIONS: PeriodOption[] = [
{ value: 'today', label: 'Vandaag' },
{ value: 'yesterday', label: 'Gisteren' },
{ value: '3days', label: 'Afgelopen 3 dagen' },
{ value: '7days', label: 'Afgelopen 7 dagen' },
];
/**
* Helper functie om datumbereik te berekenen op basis van periode
*/
export function getPeriodDateRange(period: PeriodValue): {
startDate: string;
endDate: string;
} {
const today = new Date();
const endDate = today.toISOString().split('T')[0];
let startDate: string;
switch (period) {
case 'yesterday': {
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
startDate = yesterday.toISOString().split('T')[0];
return { startDate, endDate: startDate }; // Single day
}
case '3days': {
const threeDaysAgo = new Date(today);
threeDaysAgo.setDate(threeDaysAgo.getDate() - 2);
startDate = threeDaysAgo.toISOString().split('T')[0];
break;
}
case '7days': {
const sevenDaysAgo = new Date(today);
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 6);
startDate = sevenDaysAgo.toISOString().split('T')[0];
break;
}
case 'today':
default:
startDate = endDate;
break;
}
return { startDate, endDate };
}
/**
* Helper functie voor display tekst
*/
export function getPeriodDisplayText(period: PeriodValue): string {
const option = PERIOD_OPTIONS.find((o) => o.value === period);
return option?.label || 'Vandaag';
}

View File

@@ -3,23 +3,23 @@ import localFont from "next/font/local";
import "./globals.css";
import { Toaster } from '@/components/ui/toaster';
// Serif font voor long-form content (manifesto) - lokaal geladen om build zonder netwerk te laten slagen
const crimsonText = localFont({
// Serif font voor long-form content (manifesto)
const loraFont = localFont({
variable: "--font-serif",
display: "swap",
src: [
{
path: "../docs/fonts/Lora-Regular.woff2",
path: "../public/fonts/Lora-Regular.woff2",
weight: "400",
style: "normal",
},
{
path: "../docs/fonts/Lora-Italic.woff2",
path: "../public/fonts/Lora-Italic.woff2",
weight: "400",
style: "italic",
},
{
path: "../docs/fonts/Lora-SemiBold.woff2",
path: "../public/fonts/Lora-SemiBold.woff2",
weight: "600",
style: "normal",
},
@@ -27,37 +27,37 @@ const crimsonText = localFont({
});
// Sans-serif font voor UI, nav, metadata
const inter = localFont({
const robotoFont = localFont({
variable: "--font-sans",
display: "swap",
src: [
{
path: "../docs/fonts/roboto-v47-latin-regular.woff2",
path: "../public/fonts/roboto-v47-latin-regular.woff2",
weight: "400",
style: "normal",
},
{
path: "../docs/fonts/roboto-v47-latin-500.woff2",
path: "../public/fonts/roboto-v47-latin-500.woff2",
weight: "500",
style: "normal",
},
{
path: "../docs/fonts/roboto-v47-latin-600.woff2",
path: "../public/fonts/roboto-v47-latin-600.woff2",
weight: "600",
style: "normal",
},
{
path: "../docs/fonts/roboto-v47-latin-italic.woff2",
path: "../public/fonts/roboto-v47-latin-italic.woff2",
weight: "400",
style: "italic",
},
{
path: "../docs/fonts/roboto-v47-latin-500italic.woff2",
path: "../public/fonts/roboto-v47-latin-500italic.woff2",
weight: "500",
style: "italic",
},
{
path: "../docs/fonts/roboto-v47-latin-600italic.woff2",
path: "../public/fonts/roboto-v47-latin-600italic.woff2",
weight: "600",
style: "italic",
},
@@ -65,17 +65,17 @@ const inter = localFont({
});
// Mono font voor tech details, numbers
const jetBrainsMono = localFont({
const sourceCodeProFont = localFont({
variable: "--font-mono",
display: "swap",
src: [
{
path: "../docs/fonts/source-code-pro-v30-latin-regular.woff2",
path: "../public/fonts/source-code-pro-v30-latin-regular.woff2",
weight: "400",
style: "normal",
},
{
path: "../docs/fonts/source-code-pro-v30-latin-600.woff2",
path: "../public/fonts/source-code-pro-v30-latin-600.woff2",
weight: "600",
style: "normal",
},
@@ -167,7 +167,7 @@ export default function RootLayout({
/>
</head>
<body
className={`${crimsonText.variable} ${inter.variable} ${jetBrainsMono.variable} antialiased`}
className={`${loraFont.variable} ${robotoFont.variable} ${sourceCodeProFont.variable} antialiased`}
>
{children}
<Toaster />