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:
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
176
app/api/verpleegrapportage/[patientId]/route.ts
Normal file
176
app/api/verpleegrapportage/[patientId]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
308
app/epd/verpleegrapportage/components/blocks/reports-block.tsx
Normal file
308
app/epd/verpleegrapportage/components/blocks/reports-block.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
239
app/epd/verpleegrapportage/components/patient-detail.tsx
Normal file
239
app/epd/verpleegrapportage/components/patient-detail.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
112
app/epd/verpleegrapportage/components/patient-list-row.tsx
Normal file
112
app/epd/verpleegrapportage/components/patient-list-row.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
104
app/epd/verpleegrapportage/components/patient-list.tsx
Normal file
104
app/epd/verpleegrapportage/components/patient-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
app/epd/verpleegrapportage/components/period-selector.tsx
Normal file
60
app/epd/verpleegrapportage/components/period-selector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
52
app/epd/verpleegrapportage/lib/period-utils.ts
Normal file
52
app/epd/verpleegrapportage/lib/period-utils.ts
Normal 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';
|
||||
}
|
||||
61
app/epd/verpleegrapportage/overdracht/page.tsx
Normal file
61
app/epd/verpleegrapportage/overdracht/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
app/epd/verpleegrapportage/page.tsx
Normal file
35
app/epd/verpleegrapportage/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
172
app/epd/verpleegrapportage/rapportage/[patientId]/page.tsx
Normal file
172
app/epd/verpleegrapportage/rapportage/[patientId]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
65
app/epd/verpleegrapportage/rapportage/lib/period-utils.ts
Normal file
65
app/epd/verpleegrapportage/rapportage/lib/period-utils.ts
Normal 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';
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user