diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b3b21aa --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,97 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Mini-EPD Prototype - A Dutch electronic patient dossier (EPD) system for healthcare providers. Built with Next.js 14 App Router, Supabase (PostgreSQL + Auth), and Tailwind CSS. Primary language is Dutch for UI text. + +## Development Commands + +```bash +pnpm dev # Development server at localhost:3000 +pnpm build # Production build (fails on type errors) +pnpm lint # ESLint check +pnpm types:generate # Regenerate Supabase types after schema changes +``` + +## Environment Variables + +Required in `.env.local`: +- `NEXT_PUBLIC_SUPABASE_URL` - Supabase project URL +- `NEXT_PUBLIC_SUPABASE_ANON_KEY` - Supabase anonymous key +- `ANTHROPIC_API_KEY` - Claude API for AI features +- `DEEPGRAM_API_KEY` - Deepgram for speech-to-text + +## Architecture + +### Data Layer +- **Supabase** for PostgreSQL database and authentication +- **FHIR-inspired** data model: patients, observations, conditions, encounters +- **Row Level Security (RLS)** on all tables - check policies before writing queries +- Types generated to `lib/supabase/database.types.ts` +- Server client: `lib/auth/server.ts` (for Server Components/API routes) +- Client: `lib/supabase/client.ts` (for Client Components) + +### API Structure (`app/api/`) +- `/api/reports` - Unified CRUD for all report types (verpleegkundig, observatie, incident, etc.) +- `/api/overdracht` - Handover data: patients, patient details, AI summary generation +- `/api/verpleegrapportage` - Patient data for nursing report views +- `/api/behandelplan` - Treatment plan management + +**API Route Pattern**: All routes use Zod validation, return Dutch error messages, and get the current user via `createClient()` from `lib/auth/server.ts`. + +### EPD Modules (`app/epd/`) +- `/epd/verpleegrapportage` - Overdracht overzicht (patiënten met AI-samenvatting) +- `/epd/verpleegrapportage/rapportage` - Rapportage invoer workspace (timeline view) +- `/epd/patients/[id]` - Patient dossier with intakes, conditions, observations +- `/epd/agenda` - Appointment calendar (FullCalendar) +- `/epd/clients` - Client management + +### Key Patterns + +**Report Types** (stored in `reports` table): +```typescript +type ReportType = 'voortgang' | 'observatie' | 'incident' | 'medicatie' | + 'contact' | 'crisis' | 'intake' | 'behandeladvies' | + 'vrije_notitie' | 'verpleegkundig'; +``` + +**Verpleegkundig Categories** (in `structured_data.category`): +```typescript +type Category = 'medicatie' | 'adl' | 'gedrag' | 'incident' | 'observatie'; +``` + +**Shift Date Logic**: Reports created before 07:00 are assigned to the previous day's shift. + +**Soft Delete**: Reports use `deleted_at` timestamp, not hard delete. + +### AI Integration +- Claude API for generating handover summaries (`/api/overdracht/generate`) +- Deepgram for speech-to-text (`/api/deepgram`) +- AI responses validated with Zod schemas + +### UI Components +- shadcn/ui components in `components/ui/` +- Lucide React for icons +- date-fns with Dutch locale for date formatting +- Timeline views grouped by day and day-part (nacht/ochtend/middag/avond) + +## Database Migrations + +Located in `supabase/migrations/`. Migration naming: `YYYYMMDD_description.sql` + +After schema changes: +1. Create migration file in `supabase/migrations/` +2. Apply with Supabase CLI or dashboard +3. Run `pnpm types:generate` to update TypeScript types + +## Type System + +- `lib/supabase/database.types.ts` - Auto-generated from Supabase schema (do not edit) +- `lib/types/*.ts` - Manual type definitions that extend/refine generated types + +## Documentation + +- Specs in `docs/specs/` organized by module +- Release notes in `docs/releasenotes/` diff --git a/app/api/nursing-logs/[id]/route.ts b/app/api/nursing-logs/[id]/route.ts deleted file mode 100644 index d864de4..0000000 --- a/app/api/nursing-logs/[id]/route.ts +++ /dev/null @@ -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 = {}; - 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 } - ); - } -} diff --git a/app/api/nursing-logs/route.ts b/app/api/nursing-logs/route.ts deleted file mode 100644 index 3694868..0000000 --- a/app/api/nursing-logs/route.ts +++ /dev/null @@ -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 } - ); - } -} diff --git a/app/api/overdracht/[patientId]/route.ts b/app/api/overdracht/[patientId]/route.ts index e4deba0..e883836 100644 --- a/app/api/overdracht/[patientId]/route.ts +++ b/app/api/overdracht/[patientId]/route.ts @@ -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, }; diff --git a/app/api/overdracht/generate/route.ts b/app/api/overdracht/generate/route.ts index 2aa318a..a7df45f 100644 --- a/app/api/overdracht/generate/route.ts +++ b/app/api/overdracht/generate/route.ts @@ -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>, - patientId: string + patientId: string, + period: PeriodValue ): Promise { - 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); diff --git a/app/api/overdracht/patients/route.ts b/app/api/overdracht/patients/route.ts index 233def5..f4022ec 100644 --- a/app/api/overdracht/patients/route.ts +++ b/app/api/overdracht/patients/route.ts @@ -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 diff --git a/app/api/reports/route.ts b/app/api/reports/route.ts index 37fcc56..8deb661 100644 --- a/app/api/reports/route.ts +++ b/app/api/reports/route.ts @@ -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; + + 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; - 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) { diff --git a/app/api/verpleegrapportage/[patientId]/route.ts b/app/api/verpleegrapportage/[patientId]/route.ts new file mode 100644 index 0000000..3b29234 --- /dev/null +++ b/app/api/verpleegrapportage/[patientId]/route.ts @@ -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 { + 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 } + ); + } +} diff --git a/app/epd/components/epd-sidebar.tsx b/app/epd/components/epd-sidebar.tsx index 390c8dd..1497be9 100644 --- a/app/epd/components/epd-sidebar.tsx +++ b/app/epd/components/epd-sidebar.tsx @@ -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 ( +
  • + {/* Main menu item */} + + + {/* Submenu items */} + {!isCollapsed && isExpanded && item.subItems && ( +
      + {item.subItems.map((subItem) => { + const isSubActive = pathname === subItem.href; + return ( +
    • + + {subItem.name} + +
    • + ); + })} +
    + )} + + {/* Collapsed state: show submenu on hover */} + {isCollapsed && item.subItems && ( +
    +
    + {item.name} +
    + {item.subItems.map((subItem) => { + const isSubActive = pathname === subItem.href; + return ( + + {subItem.name} + + ); + })} +
    + )} +
  • + ); +}); + 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
      {navigationItems.map((item) => ( - + item.subItems ? ( + + ) : ( + + ) ))}
    diff --git a/app/epd/dagregistratie/[patientId]/components/log-form.tsx b/app/epd/dagregistratie/[patientId]/components/log-form.tsx deleted file mode 100644 index fb7ef7c..0000000 --- a/app/epd/dagregistratie/[patientId]/components/log-form.tsx +++ /dev/null @@ -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> = { - medicatie: Pill, - adl: Utensils, - gedrag: User, - incident: AlertTriangle, - observatie: FileText, -}; - -export function LogForm({ patientId, onSuccess }: LogFormProps) { - const [category, setCategory] = useState('observatie'); - const [content, setContent] = useState(''); - const [time, setTime] = useState(format(new Date(), 'HH:mm')); - const [includeInHandover, setIncludeInHandover] = useState(false); - const [error, setError] = useState(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 ( -
    -
    -

    Nieuwe notitie

    -
    - -
    - {/* Category Selection */} -
    - -
    - {NURSING_LOG_CATEGORIES.map((cat) => { - const config = CATEGORY_CONFIG[cat]; - const Icon = CATEGORY_ICONS[cat]; - const isSelected = category === cat; - - return ( - - ); - })} -
    -
    - - {/* Time Input */} -
    - - 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" - /> -
    - - {/* Content Textarea */} -
    - -