feat(verpleegrapportage): Nieuwe module + opruiming codebase

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

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

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

Database:
- Migratie voor consolidatie nursing_logs naar reports tabel

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

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

97
CLAUDE.md Normal file
View File

@@ -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/`

View File

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

View File

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

View File

@@ -8,7 +8,7 @@ import type {
RiskAssessment,
Condition,
} from '@/lib/types/overdracht';
import type { NursingLog } from '@/lib/types/nursing-log';
import { VERPLEEG_REPORT_TYPES } from '@/lib/types/report';
interface RouteParams {
params: Promise<{ patientId: string }>;
@@ -37,7 +37,6 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
patientResult,
vitalsResult,
reportsResult,
logsResult,
risksResult,
conditionsResult,
] = await Promise.all([
@@ -57,31 +56,24 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
.gte('effective_datetime', todayStart)
.order('effective_datetime', { ascending: false }),
// 3. Reports last 24h
// 3. Reports last 24h - includes verpleegkundig (was nursing_logs) plus observatie, incident, etc
supabase
.from('reports')
.select('id, type, content, created_at, created_by')
.select('id, type, content, created_at, created_by, structured_data, include_in_handover, shift_date')
.eq('patient_id', patientId)
.in('type', [...VERPLEEG_REPORT_TYPES])
.gte('created_at', last24h)
.is('deleted_at', null)
.order('created_at', { ascending: false }),
// 4. Nursing logs today (all, not just marked)
supabase
.from('nursing_logs')
.select('*')
.eq('patient_id', patientId)
.eq('shift_date', today)
.order('timestamp', { ascending: false }),
// 5. Risks via intakes
// 4. Risks via intakes
supabase
.from('risk_assessments')
.select('id, risk_type, risk_level, rationale, created_at, intakes!inner(patient_id)')
.eq('intakes.patient_id', patientId)
.in('risk_level', ['laag', 'gemiddeld', 'hoog', 'zeer_hoog']),
// 6. Active conditions
// 5. Active conditions
supabase
.from('conditions')
.select('id, code_display, clinical_status, onset_datetime')
@@ -104,9 +96,6 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
if (reportsResult.error) {
console.error('Error fetching reports:', reportsResult.error);
}
if (logsResult.error) {
console.error('Error fetching nursing logs:', logsResult.error);
}
if (risksResult.error) {
console.error('Error fetching risks:', risksResult.error);
}
@@ -124,18 +113,18 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
effective_datetime: v.effective_datetime,
}));
// Map reports
// Map reports (now includes verpleegkundig type)
const reports: Report[] = (reportsResult.data || []).map((r) => ({
id: r.id,
type: r.type,
content: r.content,
created_at: r.created_at,
created_by: r.created_by,
structured_data: r.structured_data,
include_in_handover: r.include_in_handover,
shift_date: r.shift_date,
}));
// Nursing logs (already typed correctly from database)
const nursingLogs: NursingLog[] = logsResult.data || [];
// Map risks (remove the intakes join data)
const risks: RiskAssessment[] = (risksResult.data || []).map((r) => ({
id: r.id,
@@ -165,7 +154,6 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
},
vitals,
reports,
nursingLogs,
risks,
conditions,
};

View File

@@ -20,14 +20,14 @@ import {
type AISamenvatting,
type Aandachtspunt,
} from '@/lib/types/overdracht';
import type { NursingLog } from '@/lib/types/nursing-log';
import { VERPLEEG_REPORT_TYPES } from '@/lib/types/report';
// Zod schema for AI response validation
const AandachtspuntSchema = z.object({
tekst: z.string(),
urgent: z.boolean(),
bron: z.object({
type: z.enum(['observatie', 'rapportage', 'dagnotitie', 'risico']),
type: z.enum(['observatie', 'rapportage', 'verpleegkundig', 'risico']),
id: z.string(),
datum: z.string(),
label: z.string(),
@@ -40,23 +40,33 @@ const AIResponseSchema = z.object({
actiepunten: z.array(z.string()).max(3),
});
type PeriodValue = '1d' | '3d' | '7d' | '14d';
/**
* Calculate start date based on period
*/
function getPeriodStartDate(period: PeriodValue): string {
const days = { '1d': 1, '3d': 3, '7d': 7, '14d': 14 }[period] || 1;
const startDate = new Date();
startDate.setDate(startDate.getDate() - (days - 1));
return startDate.toISOString().split('T')[0] + 'T00:00:00.000Z';
}
/**
* Load context from database
*/
async function loadOverdrachtContext(
supabase: Awaited<ReturnType<typeof createClient>>,
patientId: string
patientId: string,
period: PeriodValue
): Promise<OverdrachtContext> {
const today = new Date().toISOString().split('T')[0];
const todayStart = `${today}T00:00:00.000Z`;
const last24h = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
const periodStart = getPeriodStartDate(period);
// Parallel queries
// Parallel queries - reports now includes verpleegkundig type
const [
patientResult,
vitalsResult,
reportsResult,
logsResult,
risksResult,
conditionsResult,
] = await Promise.all([
@@ -70,21 +80,17 @@ async function loadOverdrachtContext(
.select('id, code_display, value_quantity_value, value_quantity_unit, interpretation_code, effective_datetime')
.eq('patient_id', patientId)
.eq('category', 'vital-signs')
.gte('effective_datetime', todayStart)
.gte('effective_datetime', periodStart)
.order('effective_datetime', { ascending: false }),
// Reports now includes verpleegkundig type (was nursing_logs)
supabase
.from('reports')
.select('id, type, content, created_at, created_by')
.select('id, type, content, created_at, created_by, structured_data, include_in_handover, shift_date')
.eq('patient_id', patientId)
.gte('created_at', last24h)
.in('type', [...VERPLEEG_REPORT_TYPES])
.gte('created_at', periodStart)
.is('deleted_at', null)
.order('created_at', { ascending: false }),
supabase
.from('nursing_logs')
.select('*')
.eq('patient_id', patientId)
.eq('shift_date', today)
.order('timestamp', { ascending: false }),
supabase
.from('risk_assessments')
.select('id, risk_type, risk_level, rationale, created_at, intakes!inner(patient_id)')
@@ -131,8 +137,10 @@ async function loadOverdrachtContext(
content: r.content,
created_at: r.created_at,
created_by: r.created_by,
structured_data: r.structured_data,
include_in_handover: r.include_in_handover,
shift_date: r.shift_date,
})),
nursingLogs: (logsResult.data || []) as NursingLog[],
risks: (risksResult.data || []).map((r) => ({
id: r.id,
risk_type: r.risk_type,
@@ -218,13 +226,17 @@ async function logAIEvent(
durationMs: number
) {
try {
// Count verpleegkundige reports separately
const verpleegkundigCount = context.reports.filter(r => r.type === 'verpleegkundig').length;
const otherReportsCount = context.reports.filter(r => r.type !== 'verpleegkundig').length;
await supabase.from('ai_events').insert({
kind: 'overdracht_generate',
patient_id: patientId,
input_data: {
vitalCount: context.vitals.length,
reportCount: context.reports.length,
logCount: context.nursingLogs.length,
reportCount: otherReportsCount,
verpleegkundigCount,
riskCount: context.risks.length,
conditionCount: context.conditions.length,
},
@@ -265,7 +277,7 @@ export async function POST(request: NextRequest) {
);
}
const { patientId } = result.data;
const { patientId, period } = result.data;
const supabase = await createClient();
// Check auth
@@ -274,8 +286,8 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Niet geautoriseerd' }, { status: 401 });
}
// Load context
const context = await loadOverdrachtContext(supabase, patientId);
// Load context with period filter
const context = await loadOverdrachtContext(supabase, patientId, period);
// Call Claude API
const aiResult = await callClaudeAPI(context);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -125,11 +125,11 @@
},
{
"slug": "verpleegkundige-overdracht",
"title": "Verpleegkundige Overdracht",
"title": "Verpleegkundige Rapportage & Overdracht",
"group": "features",
"description": "Efficiënte overdracht voor GGZ verpleegkundigen met ROM-metingen",
"description": "Centrale module voor verpleegkundige notities, overdracht en AI-gestuurde samenvattingen",
"order": 10,
"status": "planned"
"status": "completed"
},
{
"slug": "spraakgestuurde-verslaglegging",

View File

@@ -1,108 +1,231 @@
---
title: "Verpleegkundige Overdracht"
title: "Verpleegkundige Rapportage & Overdracht"
category: "verpleegkundige-overdracht"
group: "features"
version: "1.0.0"
releaseDate: "TBD"
status: "planned"
description: "Efficiënte overdracht workflow voor GGZ professionals met 6+ overdrachten per dag, geïntegreerd met ROM-metingen en observaties"
releaseDate: "2025-12-08"
status: "completed"
description: "Centrale module voor verpleegkundige notities, overdracht en AI-gestuurde samenvattingen"
---
## Overview
## Overzicht
GGZ professionals in klinische settings moeten meerdere keren per dag cliënt overdrachten doen aan psychiaters, psychologen en collega's van de volgende dienst. Dit systeem helpt om dat efficiënter te maken.
**LinkedIn Feedback:**
> "Kun je iets slims bouwen voor een verpleegkundige die 6 overdrachten heeft per dag aan artsen aan de volgende dienst en ondertussen ook allerlei metingen doet en moet opslaan?"
De Verpleegkundige Rapportage & Overdracht module biedt een complete workflow voor het vastleggen van verpleegkundige notities en het voorbereiden van overdrachten aan de volgende dienst. De module bestaat uit twee hoofdschermen: **Rapportage** voor het invoeren van notities en **Overdracht** voor het overzicht met AI-samenvatting.
---
## Kernfunctionaliteit
## Navigatie
**Overdracht Workflow:**
- SBAR templates aangepast voor GGZ (Situation, Background, Assessment, Recommendation)
- Dienst schema met 6+ overdrachten per dag (nacht→dag, visite, avond→nacht)
- Automatisch ophalen van laatste observaties en ROM-scores
- Risico markers (Stabiel, Observatie, Verhoogd risico, Crisis)
Via het menu **Verpleegrapportage** met submenu:
- **Rapportage** - Invoerscherm voor verpleegkundige notities (standaard landing page)
- **Overdracht** - Overzicht van alle patiënten met rapportages en AI-samenvatting
**Observaties & Metingen:**
- Psychische observaties (stemming, angst, suïcidaliteit, agressie)
- ROM-metingen (PHQ-9, GAD-7, HONOS)
- Gedragsobservaties (slaap, eetpatroon, zelfzorg)
- Medicatie compliance en bijwerkingen
- Trend visualisatie over tijd
**Multi-Cliënt Overzicht:**
- Dashboard met alle cliënten en hun risico niveau
- Quick actions voor urgente situaties
- Template library (nieuwe opname, crisis, proefverlof, ontslag)
**Routes:**
- `/epd/verpleegrapportage` → Rapportage (invoer)
- `/epd/verpleegrapportage/overdracht` → Overdracht (overzicht + AI)
---
## Voordelen
## Rapportage Module
**Tijdsbesparing:**
- Van 10 minuten → 5 minuten per overdracht
- Automatische data aggregatie (geen handmatig opzoeken)
- Templates elimineren herhaling
### Doel
Centrale werkplek voor het vastleggen van korte verpleegkundige notities tijdens de ronde.
**Kwaliteit & Veiligheid:**
- Gestructureerde methode voorkomt vergeten details
- Continuïteit tussen diensten (cruciaal bij risico's)
- Trend visualisatie helpt vroeg signaleren
- Audit trail voor verantwoording
### Layout
Master-detail layout met:
- **Links:** Ronde overzicht met alle patiënten
- **Rechts:** Invoerformulier en tijdlijn voor geselecteerde patiënt
**Stress Reductie:**
- Duidelijk overzicht welke cliënten verhoogd risico hebben
- Prioritering automatisch (crisis eerst, daarna routine)
- Minder mental load bij grote caseload (12+ cliënten)
### Patiëntenlijst (Ronde Overzicht)
Per patiënt wordt getoond:
- Naam
- **Alert badge** (rood) - Aantal hoge risico's met tooltip "X hoog risico's"
- **Overdracht badge** (groen vinkje) - Aantal notities voor overdracht met tooltip "X voor overdracht"
Bij selectie krijgt de patiënt een teal achtergrond.
### Invoerformulier
**Risico Alert:** Bij patiënten met hoge risico's verschijnt een rode waarschuwingsbanner bovenaan.
**Categorie Selectie:** Gekleurde pills voor 5 categorieën:
| Categorie | Kleur | Icoon |
|-----------|-------|-------|
| Medicatie | Blauw | Pill |
| ADL/verzorging | Groen | Utensils |
| Gedragsobservatie | Paars | User |
| Incident | Rood | AlertTriangle |
| Algemene observatie | Grijs | FileText |
**Tekstveld:**
- Auto-resize: groeit mee met de tekst (min 56px, max 200px)
- Maximum 500 karakters met teller
- Placeholder past zich aan per categorie
**Overdracht Toggle:** Groen "Overdracht" label om notitie te markeren voor overdracht.
**Verzendknop:** Teal knop met pijl-icoon, disabled wanneer leeg.
### Notities Tijdlijn
Toont alle notities van vandaag, gegroepeerd per dagdeel:
| Dagdeel | Tijden | Icoon | Kleur |
|---------|--------|-------|-------|
| Nacht | 00:00-07:00 | Moon | Paars |
| Ochtend | 07:00-12:00 | Sunrise | Amber |
| Middag | 12:00-17:00 | Sun | Geel |
| Avond | 17:00-24:00 | Sunset | Oranje |
**Per notitie:**
- Tijdstip
- Categorie badge (gekleurd)
- Overdracht toggle (groen vinkje, klikbaar)
- Inhoud
- Bewerken (potlood-icoon, verschijnt bij hover)
- Verwijderen (prullenbak-icoon, met bevestiging)
**Statistieken:** Boven de tijdlijn: totaal aantal notities en aantal voor overdracht.
**Nachtdienst logica:** Notities vóór 07:00 worden toegewezen aan de vorige dag.
---
## Voorbeeld Scenario
## Overdracht Module
**Dienst Overdracht (22:00 Avond → Nacht):**
### Doel
Overzicht van alle patiënten met hun rapportages en AI-gestuurde samenvatting voor overdracht.
SPV loopt 12 cliënten door met nachtdienst:
- Systeem toont per cliënt: laatste observaties, risico niveau, actiepunten
- Verhoogd risico cliënten eerst (suïcidaliteit, agressie)
- Automatisch voorgevulde SBAR met wijzigingen sinds vorige dienst
- Quick export naar print of digitaal doorgeven
### Layout
Master-detail layout met:
- **Links:** Patiëntenlijst met filters en periode selector
- **Rechts:** Patiënt detail met rapportages tijdlijn en AI-samenvatting
**Crisis Alert:**
- Cliënt vertoont acute suïcidaliteit
- SPV klikt "Crisis Alert" → haalt laatste scores op
- Pre-filled urgent overdracht naar psychiater
- Push notificatie + SMS naar dienstdoende arts
- Observatie niveau automatisch verhoogd naar Q15min
### Patiëntenlijst
**Header:**
- Titel "Patiënten" met totaal aantal
- Periode selector: 24 uur (default), 3 dagen, 1 week, 2 weken
- Filter tabs: "Alle" / "Met alerts"
**Per patiënt:**
- Avatar met initialen (rood bij hoog risico)
- Naam, leeftijd, geslacht
- Alert badges:
- Rood (AlertTriangle) - Hoge risico's
- Oranje (Activity) - Afwijkende vitale waarden
- Groen (CheckCircle2) - Voor overdracht
Alle badges hebben tooltips met beschrijvende tekst.
### Patiënt Detail
**Header:**
- Naam, leeftijd, geslacht
- Quick badges (hoog risico, overdracht)
- Link naar volledig dossier
**Risico Block:** Prominent rood blok bij hoge risico's met:
- Risico type (Valrisico, Decubitus, etc.)
- Risico niveau (Zeer hoog, Hoog, Matig, Laag)
- Rationale/toelichting
**Incident Waarschuwing:** Oranje banner bij incidenten in de periode.
**Rapportages Tijdlijn:**
- Gegroepeerd per dag (Vandaag, Gisteren, datum)
- Per dag gegroepeerd per dagdeel
- Visuele tijdlijn met gekleurde nodes
- Type badges (Voortgang, Observatie, etc.)
- Categorie badges voor verpleegkundige notities
- Overdracht indicator (groen vinkje)
- Auteur en tijdstip
### AI Samenvatting
**Locatie:** Rechter kolom, sticky bij scrollen.
**Header:**
- Sparkles icoon (paars)
- Titel "AI Samenvatting"
- Geselecteerde periode label
**Genereren:**
- Knop "Genereer samenvatting"
- Loading state met spinner
- Typische duur: 3-5 seconden
**Resultaat:**
- **Samenvatting:** 1-2 zinnen met kernpunten
- **Aandachtspunten:** Max 5 items met:
- Urgentie indicator (rode rand)
- Klikbare bronverwijzing
- Type bron en datum
- **Actiepunten:** Max 3 items voor volgende dienst
**Bronverwijzingen:**
- Klikbaar: scrollt naar originele rapportage
- Highlight effect: paarse ring rond bron (2 seconden)
- Types: Verpleegkundig, Rapportage, Risicobeoordeling
**Footer:**
- Generatie tijdstip en duur
- "Vernieuwen" knop
---
## MVP Scope
## Technische Details
**Eerste versie:**
- ✅ SBAR overdracht templates voor GGZ
- ✅ Dienst schema en planning (6+ overdrachten)
- ✅ Multi-cliënt dashboard met risico indicators
- ✅ Handmatige input observaties en ROM-scores
- ✅ Crisis alert functionaliteit
### Data Model
**Later toevoegen:**
- 🔄 Voice-to-text input (hands-free tijdens observatie rondes)
- 🔄 AI samenvatting en gedragspatroon detectie
- 🔄 Mobile app voor on-the-go observaties
- 🔄 Integratie met bestaande EPD systemen (Nedap, Ysis)
- 🔄 ROM instrument koppeling (automatische import)
**Reports tabel** met type `verpleegkundig`:
- `content` - Notitie tekst (max 500 karakters)
- `structured_data.category` - Categorie (medicatie, adl, gedrag, incident, observatie)
- `include_in_handover` - Boolean voor overdracht
- `shift_date` - Berekende datum (nachtdienst logica)
### API Endpoints
| Endpoint | Methode | Beschrijving |
|----------|---------|--------------|
| `/api/reports` | GET | Ophalen rapportages met filters |
| `/api/reports` | POST | Nieuwe notitie aanmaken |
| `/api/reports/[id]` | PATCH | Notitie bewerken |
| `/api/reports/[id]` | DELETE | Notitie verwijderen (soft delete) |
| `/api/verpleegrapportage/[patientId]` | GET | Patiënt detail met rapportages en risico's |
| `/api/overdracht/patients` | GET | Patiënten overzicht met alerts |
| `/api/overdracht/generate` | POST | AI samenvatting genereren |
### Componenten
**Rapportage:**
- `RapportageWorkspace` - Hoofd container
- `QuickEntryForm` - Invoerformulier met auto-resize
- `TimelineItem` - Notitie in tijdlijn met edit/delete
**Overdracht:**
- `VerpleegrapportageClient` - Client wrapper met state
- `PatientList` - Patiëntenlijst met filters
- `PatientListRow` - Patiënt rij met badges
- `PatientDetail` - Detail view
- `ReportsBlock` - Rapportages tijdlijn
- `AISummaryBlock` - AI samenvatting met bronverwijzingen
---
## Gebruikerstips
1. **Markeer direct voor overdracht** - Scheelt tijd bij voorbereiding
2. **Gebruik "Met alerts" filter** - Snel patiënten vinden die aandacht nodig hebben
3. **Klik op bronverwijzingen** - Verifieer AI-informatie bij de bron
4. **Pas periode aan** - 3 dagen of 1 week voor meer context bij complexe patiënten
5. **Kies juiste categorie** - Helpt bij filteren en overzicht
---
## Gerelateerde Documentatie
**Features:**
- Client Management (binnenkort)
- Intake Systeem (binnenkort)
- [Spraakgestuurde Verslaglegging](/documentatie/spraakgestuurde-verslaglegging) - Voice input voor rapportages
- [Client Management](/documentatie/client-management) - Patiëntbeheer
- [Interface Design System](/documentatie/interface-design) - UI patterns
**Basis:**
- [Authentication System](/documentatie/authentication) - Toegangsbeheer
- Database Schema (binnenkort)

View File

@@ -1,69 +0,0 @@
# Intakes API
Deze custom REST API ondersteunt de intake-flow na consolidatie van `/clients/``/patients/`. Alle endpoints verwachten een geldige Supabase sessie (cookies) en geven JSON terug.
## Endpoints
### GET `/api/intakes?patientId={uuid}`
Haalt alle intakes voor één patiënt op (nieuwste eerst).
**Query parameters**
- `patientId` _(verplicht)_ — UUID van de patiënt.
**Response**
```json
{
"intakes": [
{
"id": "uuid",
"patient_id": "uuid",
"title": "Intake - Aanvang zorg",
"department": "Volwassenen",
"status": "bezig",
"start_date": "2025-11-22",
"end_date": null,
"psychologist_id": null,
"notes": null
}
],
"total": 1
}
```
### POST `/api/intakes`
Maakt een nieuwe intake.
**Body**
```json
{
"patient_id": "uuid",
"title": "Intake - Aanvang zorg",
"department": "Volwassenen",
"start_date": "2025-11-22",
"psychologist_id": "uuid?",
"notes": "optional"
}
```
**Responses**
- `201` + intake object bij succes
- `400` met `details[]` bij validatiefout
### GET `/api/intakes/{intakeId}`
Levert één intake. Retourneert `404` als het ID niet bestaat.
### PUT `/api/intakes/{intakeId}`
Partiële update. Ondersteunt `title`, `department`, `status` (`Open`/`Afgerond`), `start_date`, `end_date`, `psychologist_id`, `notes`.
### DELETE `/api/intakes/{intakeId}`
Verwijdert een intake. `204 No Content` bij succes.
## Fouten
- `401`/`403`: geen sessie of onvoldoende rechten.
- `400`: ongeldige payload (zie `details`).
- `500`: onverwachte fout; check server logs.
## Implementatieverwijzing
- Type definities: `lib/types/intake.ts`
- Server actions: `app/epd/patients/[id]/intakes/actions.ts`
- API broncode: `app/api/intakes/` en `app/api/intakes/[intakeId]/`

View File

@@ -1,304 +0,0 @@
# Component Organisatie Strategie
## Overzicht
Dit project gebruikt de **colocation pattern** voor component organisatie, een best practice in Next.js App Router architectuur.
## Twee Component Locaties
### 1. Centrale Components (`/components`)
**Doel:** Herbruikbare, generieke components die door meerdere delen van de app gebruikt worden.
**Structuur:**
```
components/
├── ui/ # Algemene UI componenten (shadcn/ui)
│ ├── button.tsx
│ ├── dialog.tsx
│ ├── dropdown-menu.tsx
│ └── ...
├── speech-recorder-streaming.tsx # Herbruikbare feature component
├── confidence-text.tsx # Herbruikbare display component
└── rich-text-editor.tsx # Herbruikbare editor component
```
**Criteria voor centrale components:**
- ✅ Gebruikt in 2+ verschillende features/routes
- ✅ Geen specifieke business logic voor één feature
- ✅ Generiek en configureerbaar via props
- ✅ Zou in een component library kunnen zitten
**Voorbeelden:**
```typescript
// ✅ Gebruikt in behandeladvies, rapportage, en andere features
import { SpeechRecorderStreaming } from '@/components/speech-recorder-streaming';
// ✅ Generieke UI component
import { Button } from '@/components/ui/button';
```
### 2. Route-Specifieke Components (`/app/.../components`)
**Doel:** Feature-specifieke components die alleen gebruikt worden binnen één route of feature.
**Structuur:**
```
app/
└── epd/
├── components/ # Gedeeld binnen EPD module
│ └── epd-sidebar.tsx
└── patients/
├── components/ # Gedeeld binnen patients feature
│ ├── patient-list.tsx
│ └── patient-form.tsx
└── [id]/
└── rapportage/
└── components/ # Specifiek voor rapportage feature
├── report-composer.tsx
├── report-timeline.tsx
└── rapportage-workspace.tsx
```
**Criteria voor route-specifieke components:**
- ✅ Gebruikt alleen binnen één feature/route
- ✅ Bevat feature-specifieke business logic
- ✅ Tight coupling met de parent route
- ✅ Geen hergebruik in andere features
**Voorbeelden:**
```typescript
// ✅ Alleen gebruikt in rapportage feature
import { ReportComposer } from './components/report-composer';
// ✅ Specifieke business logic voor behandeladvies
import { TreatmentAdviceForm } from './components/treatment-advice-form';
```
## Hiërarchie & Scope
Components worden georganiseerd op basis van hun **reuse scope**:
```
┌─────────────────────────────────────────────────────────┐
│ /components │
│ ↳ App-wide herbruikbare components │
│ (gebruikt in 2+ features) │
└─────────────────────────────────────────────────────────┘
↓ imports van
┌─────────────────────────────────────────────────────────┐
│ /app/epd/components │
│ ↳ EPD module-wide components │
│ (gedeeld tussen patient, intake, rapportage) │
└─────────────────────────────────────────────────────────┘
↓ imports van
┌─────────────────────────────────────────────────────────┐
│ /app/epd/patients/components │
│ ↳ Patient feature components │
│ (gedeeld tussen patient routes) │
└─────────────────────────────────────────────────────────┘
↓ imports van
┌─────────────────────────────────────────────────────────┐
│ /app/epd/patients/[id]/rapportage/components │
│ ↳ Rapportage page-specifieke components │
│ (alleen gebruikt in rapportage) │
└─────────────────────────────────────────────────────────┘
```
## Statistieken (Huidige State)
- **Centrale components**: 18 components
- **Route-specifieke components**: 56 components
- **Duplicaten**: 0 ✅
## Voordelen van Deze Aanpak
### 1. **Betere Code Organisation**
- Components staan dichtbij waar ze gebruikt worden
- Makkelijker te vinden en te onderhouden
- Duidelijke scope en ownership
### 2. **Betere Performance**
- Kleinere bundles per route (code splitting)
- Alleen relevante components worden geladen
- Tree-shaking werkt beter
### 3. **Betere Developer Experience**
- Minder zoeken in grote component directories
- Duidelijk wanneer een component herbruikbaar is
- Makkelijker refactoren
### 4. **Schaalbaarheid**
- Nieuwe features kunnen onafhankelijk components toevoegen
- Geen "god component folder" met 100+ bestanden
- Teams kunnen parallel werken zonder conflicts
## Decision Tree: Waar plaats ik een component?
```
Wordt de component gebruikt in 2+ verschillende features?
├─ Ja → Is het een generieke UI component (button, dialog, etc)?
│ │
│ ├─ Ja → /components/ui/{name}.tsx
│ │
│ └─ Nee → /components/{name}.tsx
└─ Nee → Wordt het gedeeld binnen een feature module?
├─ Ja → /app/{feature}/components/{name}.tsx
└─ Nee → /app/{feature}/{subfeature}/components/{name}.tsx
```
## Voorbeelden
### ✅ Goed: SpeechRecorderStreaming in centrale folder
**Waarom?** Gebruikt in meerdere features:
```typescript
// app/epd/patients/[id]/rapportage/components/report-composer.tsx
import { SpeechRecorderStreaming } from '@/components/speech-recorder-streaming';
// app/epd/patients/[id]/intakes/[intakeId]/behandeladvies/components/treatment-advice-form.tsx
import { SpeechRecorderStreaming } from '@/components/speech-recorder-streaming';
```
### ✅ Goed: ReportComposer in rapportage/components
**Waarom?** Alleen gebruikt in rapportage feature:
```typescript
// app/epd/patients/[id]/rapportage/page.tsx
import { ReportComposer } from './components/report-composer';
```
### ❌ Fout: Generieke Button in route folder
```typescript
// ❌ NIET DOEN
// app/epd/patients/components/button.tsx
export function Button() { ... }
// ✅ WEL DOEN
// components/ui/button.tsx
export function Button() { ... }
```
### ❌ Fout: Feature-specifieke component in centrale folder
```typescript
// ❌ NIET DOEN
// components/report-composer.tsx (alleen gebruikt in rapportage)
// ✅ WEL DOEN
// app/epd/patients/[id]/rapportage/components/report-composer.tsx
```
## Refactoring Workflow
### Wanneer een route-component herbruikbaar wordt:
1. **Identificeer hergebruik**
```bash
# Check waar component gebruikt wordt
grep -r "import.*ComponentName" app/
```
2. **Verplaats naar centrale folder**
```bash
mv app/feature/components/component.tsx components/
```
3. **Update alle imports**
```typescript
// Van:
import { Component } from '../components/component';
// Naar:
import { Component } from '@/components/component';
```
4. **Generaliseer indien nodig**
- Verwijder feature-specifieke logic
- Maak configureerbaar via props
- Update TypeScript types
### Wanneer een centrale component feature-specifiek wordt:
(Dit komt zelden voor, maar kan gebeuren)
1. Check of component echt nergens anders gebruikt wordt
2. Verplaats naar meest specifieke route waar het gebruikt wordt
3. Update imports
## Related Patterns
### Server vs Client Components
```typescript
// Server Component (default in app/)
export default function ReportPage() { ... }
// Client Component (expliciet markeren)
'use client';
export function ReportComposer() { ... }
```
Route-specifieke components kunnen zowel server als client components zijn.
Centrale components zijn meestal client components (interactief).
### Composition Pattern
Route-specifieke components kunnen centrale components gebruiken:
```typescript
// app/epd/patients/[id]/rapportage/components/report-composer.tsx
import { SpeechRecorderStreaming } from '@/components/speech-recorder-streaming';
import { Button } from '@/components/ui/button';
export function ReportComposer() {
return (
<div>
<SpeechRecorderStreaming />
<Button>Save</Button>
</div>
);
}
```
## Best Practices
1. **Start route-specifiek** - Begin met components in route folders, verplaats alleen naar centraal als er echt hergebruik is
2. **Gebruik absolute imports** - `@/components/...` voor centrale, relative voor route-specifieke
3. **Avoid premature abstraction** - Wacht tot een component 2x gebruikt wordt voordat je het generaliseert
4. **Keep it colocated** - Plaats components zo dichtbij mogelijk bij waar ze gebruikt worden
5. **Document reusability** - Als een component generiek is, documenteer dan het gebruik in JSDoc
## Tools & Commands
### Find all components in a route:
```bash
find app/epd/patients/[id]/rapportage -name "*.tsx" -type f
```
### Check component usage:
```bash
grep -r "import.*ComponentName" app/
```
### Count components per location:
```bash
find components -name "*.tsx" | wc -l
find app -path "*/components/*" -name "*.tsx" | wc -l
```
## References
- [Next.js App Router: Project Organization](https://nextjs.org/docs/app/building-your-application/routing/colocation)
- [React: Thinking in React](https://react.dev/learn/thinking-in-react)
- [Component Composition Patterns](https://www.patterns.dev/react/compound-pattern)
---
**Last Updated:** 2024-11-24
**Status:** Active pattern in gebruik

View File

@@ -1,206 +0,0 @@
# Hoe werkt de Authenticatie Flow? 🔐
Een simpele uitleg van wat er gebeurt wanneer gebruikers zich aanmelden.
---
## 📧 Email Confirmatie Flow (nieuwe gebruikers)
### Stap 1: Gebruiker meldt zich aan
```
Gebruiker vult in op /login:
├─ Email: jan@example.com
└─ Wachtwoord: Geheim123!
```
### Stap 2: Supabase stuurt email
```
Supabase maakt account aan → Stuurt bevestigingsmail
De email bevat een link zoals:
https://aispeedrun.nl/auth/callback?token=xyz123&type=signup
└─────┬─────┘
Dit is de redirect URL!
```
### Stap 3: Gebruiker klikt op link in email
```
Browser gaat naar: /auth/callback?token=xyz123
De callback route doet:
1. ✅ Controleert de token
2. ✅ Activeert het account
3. ✅ Logt gebruiker in
4. → Stuurt door naar /epd/clients (omdat ze al wachtwoord hebben)
```
---
## 🔑 Wachtwoord Reset Flow
### Stap 1: Gebruiker klikt "Wachtwoord vergeten?"
```
Gaat naar: /reset-password
Vult in: jan@example.com
```
### Stap 2: Supabase stuurt reset email
```
Email bevat link:
https://aispeedrun.nl/auth/callback?token=abc789&type=recovery&next=/update-password
└─────┬─────┘ └────┬────┘
Callback route Waar naartoe daarna?
```
### Stap 3: Gebruiker klikt link
```
/auth/callback ontvangt de token
├─ Controleert token ✅
├─ Logt gebruiker tijdelijk in
└─ Redirect naar: /update-password (van de 'next' parameter)
```
### Stap 4: Nieuw wachtwoord instellen
```
Op /update-password:
├─ Gebruiker vult nieuw wachtwoord in
├─ Wachtwoord wordt opgeslagen
└─ Redirect naar /login → Gebruiker kan inloggen!
```
---
## ✉️ Magic Link Flow (oude methode)
### Stap 1: Gebruiker vraagt magic link aan
```
Vult alleen email in (geen wachtwoord)
```
### Stap 2: Email met magic link
```
Link: https://aispeedrun.nl/auth/callback?token=magic456
```
### Stap 3: Eerste keer inloggen
```
/auth/callback detecteert: "nieuwe magic link gebruiker"
└─ Redirect naar /set-password (optioneel wachtwoord instellen)
├─ Wachtwoord instellen → /epd/clients
└─ Overslaan → /epd/clients (blijf magic link gebruiken)
```
---
## 🌐 Waarom de Redirect URLs belangrijk zijn
Supabase moet weten welke URLs **veilig** zijn om naar terug te sturen.
### Zonder redirect URLs in Supabase:
```
❌ Link in email: https://aispeedrun.nl/auth/callback?token=xyz
Supabase zegt: "Deze URL ken ik niet, BLOCKED!"
Gebruiker ziet error 😞
```
### Met redirect URLs in Supabase:
```
✅ Link in email: https://aispeedrun.nl/auth/callback?token=xyz
Supabase zegt: "Deze URL staat in mijn lijst, OK!"
Gebruiker wordt ingelogd en doorgestuurd 🎉
```
---
## 🔧 De Site URL vs Redirect URLs
### Site URL (1 URL)
```
Dit is je "hoofd" URL waar Supabase denkt dat je app draait.
Supabase gebruikt dit voor:
├─ {{ .ConfirmationURL }} in emails (de basis)
└─ Default redirects
Development: http://localhost:3000
Production: https://aispeedrun.nl
```
### Redirect URLs (meerdere URLs mogelijk)
```
Dit is de "whitelist" van URLs waar Supabase naartoe MAG redirecten.
Je moet ALLE mogelijke auth callbacks toevoegen:
├─ /auth/callback → Email confirmaties, magic links
├─ /update-password → Na password reset
├─ /set-password → Nieuwe users (optioneel wachtwoord)
└─ /reset-password → Password reset pagina
Voor zowel localhost als productie!
```
---
## 🎯 Simpel Gezegd
1. **Site URL** = Waar draait je app?
- Tijdens development: `http://localhost:3000`
- Live op internet: `https://aispeedrun.nl`
2. **Redirect URLs** = Welke paginas mag Supabase bezoeken na login/reset?
- Voeg ALLE auth-gerelateerde URLs toe
- Voor zowel development als productie
3. **Email links** = Gebouwd met Site URL + token
- Als Site URL = localhost → emails gaan naar localhost ❌
- Als Site URL = aispeedrun.nl → emails gaan naar je website ✅
---
## 📝 Voorbeeld Flow in de Praktijk
```
[Gebruiker]
↓ Registreert op /login
[Jouw App]
↓ POST naar Supabase "maak account"
[Supabase]
↓ Stuurt email naar gebruiker
↓ Email link = [Site URL]/auth/callback?token=xyz
[Email Inbox]
↓ Gebruiker klikt link
[Browser]
↓ Gaat naar aispeedrun.nl/auth/callback?token=xyz
[Supabase]
↓ Checkt: staat "aispeedrun.nl/auth/callback" in Redirect URLs?
↓ JA ✅ → Verifieert token
[Jouw App - /auth/callback route]
↓ Token geldig? → Login gebruiker
↓ Nieuwe gebruiker met wachtwoord?
↓ JA → Redirect naar /epd/clients
[Gebruiker is ingelogd! 🎉]
```
---
## ❓ Veelgestelde Vragen
### Waarom krijg ik localhost links in productie emails?
→ Je Site URL staat nog op `http://localhost:3000` in Supabase. Wijzig naar `https://aispeedrun.nl`
### Waarom krijg ik "Invalid Redirect URL" errors?
→ De URL staat niet in je Redirect URLs lijst. Voeg hem toe in Supabase Dashboard.
### Kan ik zowel localhost als productie tegelijk gebruiken?
→ JA! Voeg beide toe aan Redirect URLs. Wissel alleen de Site URL afhankelijk van waar je test.
### Moet ik www. ook toevoegen?
→ Als je site bereikbaar is via `www.aispeedrun.nl`, voeg dan ook die URLs toe.
---
**Hopelijk is het nu duidelijk! 🚀**

View File

@@ -1,183 +0,0 @@
# Auth Hook Setup Guide
## Overzicht
Deze hook detecteert duplicate emails VOOR een user wordt aangemaakt,
waardoor gebruikers direct feedback krijgen als hun email al geregistreerd is.
**Voordelen:**
- ✅ Server-side validatie (kan niet omzeild worden)
- ✅ Duidelijke foutmeldingen voor gebruikers
- ✅ Betrouwbaar (werkt ongeacht password)
- ✅ Case-insensitive email matching
- ✅ Email normalisatie (lowercase + trim)
## Setup (Eerste Keer)
### Stap 1: Deploy Migration
**Optie A: Via Supabase Dashboard (Aanbevolen)**
1. Ga naar: [Supabase Dashboard → SQL Editor](https://supabase.com/dashboard/project/dqugbrpwtisgyxscpefg/sql)
2. Open het migration bestand: `supabase/migrations/20251119094908_auth_hook_duplicate_email.sql`
3. Kopieer de volledige inhoud
4. Plak in de SQL Editor
5. Klik "RUN" om de functie aan te maken
**Optie B: Via Supabase CLI (Als geconfigureerd)**
```bash
npx supabase db push
```
### Stap 2: Verificatie & Instructies
Run het setup script om te verifiëren dat de functie bestaat:
```bash
pnpm run setup:auth-hook
```
Dit script:
- ✅ Checkt of de functie bestaat
- 📋 Geeft instructies voor Dashboard configuratie
- 🔗 Biedt directe links naar relevante Dashboard pagina's
### Stap 3: Configureer Hook Link
**⚠️ Deze stap moet handmatig via Dashboard** (Supabase ondersteunt dit nog niet via API):
1. Ga naar: [Supabase Dashboard → Auth → Hooks](https://supabase.com/dashboard/project/dqugbrpwtisgyxscpefg/auth/hooks)
2. Klik **"Add a new hook"** of **"Enable Hooks"**
3. Vul in:
- **Hook Type:** "Send a hook on before a user is created" (`before-user-created`)
- **Select hook:** "Postgres Function"
- **Schema:** `public`
- **Function Name:** `hook_check_duplicate_email`
4. Klik **"Create hook"** of **"Save"**
### Stap 4: Test
Test de hook door:
1. Ga naar je signup pagina: `http://localhost:3000/login`
2. Probeer te registreren met een **bestaand** emailadres (bijv. `demo@mini-ecd.demo`)
3. Je zou een error moeten zien: _"Dit emailadres is al geregistreerd. Probeer in te loggen of gebruik 'Wachtwoord vergeten?'."_
4. Probeer te registreren met een **nieuw** emailadres
5. Dit zou normaal moeten werken (verificatie email verzonden)
## Test Cases
| Test Case | Scenario | Expected Result |
|-----------|----------|-----------------|
| TC1 | Signup met nieuw email | ✅ Account aangemaakt, email verzonden |
| TC2 | Signup met bestaand email | ❌ Error: "Dit emailadres is al geregistreerd..." |
| TC3 | Signup met bestaand email (case variant: `Email@Example.com`) | ❌ Error (case-insensitive match) |
| TC4 | Signup met lege/NULL email | ❌ Error: "Email adres is verplicht." |
| TC5 | Hook disabled → signup met bestaand email | ⚠️ Oude gedrag (geen error, maar ook geen email) |
## Herhaalbaarheid
-**Functie code** staat in migrations (version controlled)
- ⚠️ **Hook link** moet per omgeving handmatig worden geconfigureerd
-**Documentatie** staat in Git
-**Setup script** voor validatie en instructies
## Technische Details
### Wat Doet de Hook?
De `hook_check_duplicate_email` functie:
1. Ontvangt signup event van Supabase Auth
2. Haalt email adres uit event payload
3. Valideert email (niet NULL/empty)
4. Normaliseert email (lowercase + trim)
5. Checkt of email al bestaat in `auth.users` table (case-insensitive)
6. Als email bestaat → return error object
7. Als email nieuw is → return empty object (allow signup)
### Security
- **Security Definer:** Functie draait met elevated permissions
- **Search Path:** Expliciet ingesteld op `public, auth` voor veilige schema access
- **Permissions:** Alleen `supabase_auth_admin` kan de functie uitvoeren
- **Email Enumeration Protection:** Werkt samen met bestaande email confirmation
### Performance
- ⚡ Direct database check (geen extra HTTP calls)
- ⚡ Indexed lookup op `auth.users.email`
- ⚡ Minimale overhead (< 10ms typisch)
## Toekomstige Verbeteringen
Zodra Supabase Management API Auth Hooks ondersteunt, kunnen we:
- [ ] Hook link volledig automatiseren
- [ ] Setup script uitbreiden met API calls
- [ ] CI/CD pipeline voor hook configuratie
- [ ] Automated tests voor hook functionaliteit
## Flexibiliteit
De functie is geschreven in standaard PostgreSQL, waardoor:
- ✅ Werkt met elke auth provider die Postgres functies ondersteunt
- ✅ Makkelijk te migreren naar andere auth systemen
- ✅ Geen vendor lock-in voor de logica zelf
## Troubleshooting
### "Function does not exist" error
**Probleem:** De hook functie is niet aangemaakt in de database.
**Oplossing:**
1. Controleer of migration is uitgevoerd via Dashboard of CLI
2. Run `pnpm run setup:auth-hook` voor verificatie
3. Check Supabase logs voor SQL errors
### Hook lijkt niet te werken
**Probleem:** Signup met bestaand email geeft geen error.
**Mogelijke oorzaken:**
1. Hook link niet geconfigureerd in Dashboard → Ga naar Auth → Hooks
2. Hook is disabled → Check hook status in Dashboard
3. Email confirmation staat uit → Check Auth → Email Templates
**Verificatie:**
```sql
-- Check of functie bestaat
SELECT routine_name
FROM information_schema.routines
WHERE routine_schema = 'public'
AND routine_name = 'hook_check_duplicate_email';
-- Test functie handmatig
SELECT hook_check_duplicate_email('{"user": {"email": "demo@mini-ecd.demo"}}'::jsonb);
```
### Wrong error message
**Probleem:** Error message klopt niet of is in het Engels.
**Oplossing:**
1. Check of je de laatste versie van de migration hebt gebruikt
2. Update functie via SQL Editor met correcte error messages
3. Rebuild client error handling (`app/login/page.tsx`)
## Related Documentation
- [Supabase Auth Hooks Documentation](https://supabase.com/docs/guides/auth/auth-hooks)
- [Bouwplan: Auth Hook Implementation](./bouwplan-auth-hook-duplicate-email-v1.0.md)
- [Main README](../README.md)
## Support
Voor vragen of problemen:
1. Check deze documentatie
2. Check Supabase logs in Dashboard
3. Run `pnpm run setup:auth-hook` voor diagnostics
4. Review `supabase/migrations/20251119094908_auth_hook_duplicate_email.sql`

View File

@@ -1,416 +0,0 @@
# 🔐 Authentication Setup Guide
**Project:** AI Speedrun - Mini-ECD Prototype
**Epic:** E2 - Database & Auth
**Story:** E2.S3 - Demo auth flow
**Last Updated:** 2024-11-15
---
## Overview
This document describes the authentication implementation for the EPD prototype, including magic link login and demo user accounts.
---
## Authentication Methods
### 1. Magic Link (Primary Method)
Users can sign in using email-only authentication:
1. User enters email on `/login`
2. Supabase sends magic link to email
3. User clicks link → auto-logged in
4. **New users:** Account is automatically created on first magic link request
**Benefits:**
- No password to remember
- More secure than traditional passwords
- Better UX for demo environment
- Auto-creates accounts (no separate signup flow needed)
---
### 2. Demo Accounts (For Presentations)
Pre-configured demo accounts for public demos and presentations:
| Email | Password | Access Level | Purpose |
|-------|----------|--------------|---------|
| demo@mini-ecd.demo | Demo2024! | interactive | Main demo account - full CRUD |
| readonly@mini-ecd.demo | Demo2024! | read_only | View-only for public demos |
| presenter@mini-ecd.demo | Demo2024! | presenter | Live presentations |
**Access Levels:**
- `read_only`: Can view all data, cannot create/edit/delete
- `interactive`: Full CRUD access to all features
- `presenter`: Full access + special presenter features (future)
---
## Setup Instructions
### 1. Environment Variables
Ensure these are set in your `.env.local`:
```bash
# Supabase
NEXT_PUBLIC_SUPABASE_URL=https://dqugbrpwtisgyxscpefg.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
# Service role key (for admin operations)
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
```
### 2. Create Demo Users
Run the seed script to create demo user accounts:
```bash
# Make sure you have tsx installed
pnpm add -D tsx
# Run the seed script
tsx scripts/seed-demo-users.ts
```
**Expected output:**
```
🌱 Starting demo user seed...
Creating user: demo@mini-ecd.demo...
✅ Created auth user: xxx-xxx-xxx
✅ Created demo_users entry
✨ demo@mini-ecd.demo ready!
Creating user: readonly@mini-ecd.demo...
✅ Created auth user: xxx-xxx-xxx
✅ Created demo_users entry
✨ readonly@mini-ecd.demo ready!
Creating user: presenter@mini-ecd.demo...
✅ Created auth user: xxx-xxx-xxx
✅ Created demo_users entry
✨ presenter@mini-ecd.demo ready!
✅ Demo user seed complete!
```
### 3. Configure Supabase Auth Settings
Go to Supabase Dashboard → Authentication → Settings:
#### Email Templates
Customize the magic link email template:
**Subject:** "Login to Mini-ECD"
**Body:**
```html
<h2>Je magic link is klaar!</h2>
<p>Klik op de knop hieronder om in te loggen bij Mini-ECD:</p>
<p><a href="{{ .ConfirmationURL }}">Login naar EPD</a></p>
<p>Of kopieer deze link naar je browser:</p>
<p>{{ .ConfirmationURL }}</p>
<p><small>Deze link is 1 uur geldig.</small></p>
```
#### Redirect URLs
Add these redirect URLs under "Redirect URLs":
```
http://localhost:3000/auth/callback
https://yourdomain.com/auth/callback
```
#### Email Auth Settings
- ✅ Enable Email provider
- ✅ Confirm email: OFF (for demo convenience)
- ✅ Secure email change: ON
- ⏱️ Rate limits: Default (4 emails per hour)
---
## File Structure
```
app/
login/
page.tsx # Login UI (magic link + demo login)
auth/
callback/
route.ts # Handles magic link callback
logout/
route.ts # Logout endpoint
lib/
auth/
client.ts # Client-side auth helpers
server.ts # Server-side auth helpers
database.types.ts # Generated Supabase types
middleware.ts # Route protection
scripts/
seed-demo-users.ts # Demo user creation script
```
---
## Usage Examples
### Client-Side (React Components)
```typescript
import {
loginWithMagicLink,
loginWithPassword,
logout,
getUser,
isDemoUser
} from '@/lib/auth/client'
// Magic link login
async function handleMagicLink(email: string) {
const result = await loginWithMagicLink(email)
console.log(result.message) // "Check je email voor de magic link!"
}
// Demo account login
async function handleDemoLogin() {
await loginWithPassword('demo@mini-ecd.demo', 'Demo2024!')
router.push('/clients')
}
// Check current user
const user = await getUser()
const isDemo = await isDemoUser()
// Logout
await logout() // Redirects to /login
```
### Server-Side (API Routes, Server Components)
```typescript
import {
requireAuth,
getUser,
canWrite,
getDemoUserInfo
} from '@/lib/auth/server'
// Require authentication in API route
export async function GET() {
const session = await requireAuth() // Throws if not authenticated
// ... handle request
}
// Check write permissions
export async function POST() {
const hasWriteAccess = await canWrite()
if (!hasWriteAccess) {
return NextResponse.json(
{ error: 'Read-only demo account cannot create data' },
{ status: 403 }
)
}
// ... create resource
}
// Get demo user info
const demoInfo = await getDemoUserInfo()
if (demoInfo) {
console.log(`Access level: ${demoInfo.access_level}`)
console.log(`Usage count: ${demoInfo.usage_count}`)
}
```
---
## Route Protection
Routes are protected via `middleware.ts`:
### Public Routes (No Auth Required)
- `/` - Landing page
- `/login` - Login page
- `/epd` - EPD demo info
- `/contact` - Contact form
- `/auth/callback` - Auth callback
### Protected Routes (Auth Required)
- `/clients` - Client list
- `/clients/*` - Client details, intake, etc.
- Any other route not in public list
**Behavior:**
- ✅ Unauthenticated → Redirect to `/login?redirect=/original-path`
- ✅ Authenticated on `/login` → Redirect to `/clients`
- ✅ Session auto-refreshed in middleware
---
## Security Features
### ✅ Implemented
1. **RLS Policies**: All database queries filtered by `auth.uid()`
2. **Session Management**: Auto-refresh tokens via middleware
3. **Secure Cookies**: HTTP-only, secure flags set
4. **CSRF Protection**: Built-in Next.js CSRF protection
5. **Rate Limiting**: Supabase default (4 emails/hour)
6. **Demo User Tracking**: Usage count and last login tracked
### 🔒 Production Enhancements
For production deployment:
1. **Email Confirmation**: Enable email confirmation
2. **Password Requirements**: Enforce strong passwords
3. **MFA**: Add multi-factor authentication
4. **Session Timeout**: Implement auto-logout after inactivity
5. **IP Whitelisting**: Restrict demo accounts to specific IPs
6. **Audit Logging**: Enhanced tracking of all auth events
---
## Demo User Management
### Checking Demo Status
```typescript
// Check if user is demo user
const isDemo = await isDemoUser()
// Get access level
const accessLevel = await getDemoAccessLevel()
// Returns: 'read_only' | 'interactive' | 'presenter' | null
```
### Restricting Actions
```typescript
// In API route
const demoInfo = await getDemoUserInfo()
if (demoInfo?.access_level === 'read_only') {
return NextResponse.json(
{ error: 'This demo account is read-only' },
{ status: 403 }
)
}
```
### Resetting Demo Accounts
To reset a demo account (clear data, reset usage):
```sql
-- Reset usage count
UPDATE demo_users
SET usage_count = 0, last_login_at = NULL
WHERE access_level = 'interactive';
-- Or via Supabase Dashboard: Authentication → Users → Delete user data
```
---
## Troubleshooting
### Issue: Magic link not arriving
**Causes:**
- Email in spam folder
- Rate limit exceeded (4 emails/hour)
- Email provider blocking Supabase emails
**Solutions:**
1. Check spam folder
2. Wait 1 hour and try again
3. Use demo account instead
4. Configure custom SMTP in Supabase
### Issue: "Invalid login credentials"
**Causes:**
- Wrong email/password for demo account
- Demo user not created yet
**Solutions:**
1. Check credentials match exactly (case-sensitive)
2. Run seed script: `tsx scripts/seed-demo-users.ts`
3. Verify in Supabase Dashboard → Authentication → Users
### Issue: Redirect loop on /login
**Causes:**
- Middleware configuration error
- Session cookie issues
**Solutions:**
1. Clear browser cookies
2. Check middleware.ts public routes config
3. Verify `NEXT_PUBLIC_SUPABASE_URL` is correct
### Issue: "Row violates RLS policy" errors
**Causes:**
- User not properly authenticated
- Session expired
- RLS policies misconfigured
**Solutions:**
1. Logout and login again
2. Check `auth.uid()` returns valid UUID
3. Verify RLS policies allow user access
---
## Testing Checklist
### Magic Link Flow
- [ ] Can enter email on /login
- [ ] Magic link email received
- [ ] Clicking link redirects to /clients
- [ ] Session persists after page refresh
- [ ] New users auto-created on first login
### Demo Account Flow
- [ ] Can login with demo@mini-ecd.demo
- [ ] Can login with readonly@mini-ecd.demo
- [ ] Interactive account can create/edit data
- [ ] Read-only account blocked from editing
- [ ] Demo usage tracked in demo_users table
### Route Protection
- [ ] /clients redirects to /login when not authenticated
- [ ] /login redirects to /clients when authenticated
- [ ] Public routes accessible without auth
- [ ] Session auto-refreshes
### Logout
- [ ] Logout clears session
- [ ] Redirects to /login
- [ ] Cannot access protected routes after logout
---
## References
- [Supabase Auth Documentation](https://supabase.com/docs/guides/auth)
- [Next.js Middleware](https://nextjs.org/docs/app/building-your-application/routing/middleware)
- Build Plan: `docs/specs/bouwplan-ai-speedrun-marketing-first-v1.1.md` Epic 2
- Technical Design: `docs/specs/to-mini-ecd-v1_2.md` § 5.7
---
**Status:** ✅ Implemented and Ready for Testing
**Next Steps:** E2.S4 - Seed data script (clients + dossiers)

View File

@@ -1,304 +0,0 @@
# 🔒 Row Level Security (RLS) Documentation
**Project:** AI Speedrun - Mini-ECD Prototype
**Epic:** E2 - Database & Auth
**Story:** E2.S2 - RLS policies implementeren
**Last Updated:** 2024-11-15
---
## Overview
This document describes the Row Level Security (RLS) implementation for the EPD core database tables. RLS is PostgreSQL's security feature that restricts which rows users can access in database queries.
### Security Model
- **Authentication Required:** All data access requires a valid Supabase authentication session
- **Authorization:** Checked via `auth.uid()` function which returns the authenticated user's UUID
- **MVP Level:** All authenticated users can access all data (suitable for demo/single-org)
- **Production Path:** Ready to extend with `org_id` filtering for multi-tenancy
---
## Tables & Policies
### 1. Clients Table
**Purpose:** Basic client information
**RLS Enabled:** ✅ Yes
#### Policies:
| Policy Name | Operation | Rule |
|------------|-----------|------|
| Authenticated users can view clients | SELECT | `auth.uid() IS NOT NULL` |
| Authenticated users can create clients | INSERT | `auth.uid() IS NOT NULL` |
| Authenticated users can update clients | UPDATE | `auth.uid() IS NOT NULL` |
| Authenticated users can delete clients | DELETE | `auth.uid() IS NOT NULL` |
**Production Enhancement:**
```sql
-- Add organization filtering
CREATE POLICY "Users can view own org clients"
ON clients FOR SELECT
USING (
auth.uid() IS NOT NULL AND
org_id = (SELECT org_id FROM users WHERE id = auth.uid())
);
```
---
### 2. Intake Notes Table
**Purpose:** TipTap/ProseMirror JSON content storage
**RLS Enabled:** ✅ Yes
#### Policies:
| Policy Name | Operation | Rule |
|------------|-----------|------|
| Authenticated users can view intake notes | SELECT | `auth.uid() IS NOT NULL` |
| Authenticated users can create intake notes | INSERT | `auth.uid() IS NOT NULL` |
| Authenticated users can update intake notes | UPDATE | `auth.uid() IS NOT NULL` |
| Authenticated users can delete intake notes | DELETE | `auth.uid() IS NOT NULL` |
**Security Features:**
- Full-text search index with Dutch language support
- Cascade delete when parent client is deleted
- Automatic `updated_at` trigger
---
### 3. Problem Profiles Table
**Purpose:** DSM-light categorization with severity scoring
**RLS Enabled:** ✅ Yes
#### Policies:
| Policy Name | Operation | Rule |
|------------|-----------|------|
| Authenticated users can view problem profiles | SELECT | `auth.uid() IS NOT NULL` |
| Authenticated users can create problem profiles | INSERT | `auth.uid() IS NOT NULL` |
| Authenticated users can update problem profiles | UPDATE | `auth.uid() IS NOT NULL` |
| Authenticated users can delete problem profiles | DELETE | `auth.uid() IS NOT NULL` |
**Data Constraints:**
- Category: Must be one of 6 DSM-light categories
- Severity: Must be 'laag', 'middel', or 'hoog'
- Cascade delete with parent client
- SET NULL on source note deletion
---
### 4. Treatment Plans Table
**Purpose:** Treatment plans with JSONB structure and versioning
**RLS Enabled:** ✅ Yes
#### Policies:
| Policy Name | Operation | Rule |
|------------|-----------|------|
| Authenticated users can view treatment plans | SELECT | `auth.uid() IS NOT NULL` |
| Authenticated users can create treatment plans | INSERT | `auth.uid() IS NOT NULL` |
| Authenticated users can update treatment plans | UPDATE | `auth.uid() IS NOT NULL` |
| Authenticated users can delete treatment plans | DELETE | `auth.uid() IS NOT NULL` |
**Versioning:**
- Each client can have multiple versions (v1, v2, etc.)
- Status: 'concept' (editable) or 'gepubliceerd' (locked)
- UNIQUE constraint on (client_id, version)
---
### 5. AI Events Table
**Purpose:** Telemetry and debugging for AI API calls
**RLS Enabled:** ✅ Yes
**Special:** Append-only (no UPDATE/DELETE for regular users)
#### Policies:
| Policy Name | Operation | Rule |
|------------|-----------|------|
| Authenticated users can view AI events | SELECT | `auth.uid() IS NOT NULL` |
| Authenticated users can create AI events | INSERT | `auth.uid() IS NOT NULL` |
| ~~UPDATE~~ | ❌ | Not allowed (audit trail) |
| ~~DELETE~~ | ❌ | Not allowed (audit trail) |
**Immutability:**
- Regular users cannot modify or delete AI events
- Ensures audit trail integrity
- Service role can bypass RLS for admin cleanup
---
## Testing RLS
### Test 1: Verify RLS is Enabled
```sql
SELECT tablename, rowsecurity as rls_enabled
FROM pg_tables
WHERE schemaname = 'public'
ORDER BY tablename;
```
**Expected Result:**
All 5 tables should show `rls_enabled: true`
### Test 2: Check Policy Count
```sql
SELECT
tablename,
COUNT(*) as policy_count,
STRING_AGG(cmd, ', ' ORDER BY cmd) as commands
FROM pg_policies
WHERE schemaname = 'public'
GROUP BY tablename;
```
**Expected Result:**
- `ai_events`: 2 policies (INSERT, SELECT)
- Other tables: 4 policies each (DELETE, INSERT, SELECT, UPDATE)
### Test 3: Verify Authentication Check
```sql
SELECT tablename, policyname, cmd, qual
FROM pg_policies
WHERE schemaname = 'public'
AND qual NOT LIKE '%auth.uid()%';
```
**Expected Result:**
Empty (all policies use `auth.uid()` checks)
---
## TypeScript Integration
TypeScript types are auto-generated and available at `lib/database.types.ts`:
```typescript
import type { Database } from '@/lib/database.types'
// Usage with Supabase client
const supabase = createClient<Database>(url, key)
// Type-safe queries
const { data: clients } = await supabase
.from('clients')
.select('*')
// Insert with type checking
const { data: newClient } = await supabase
.from('clients')
.insert({
first_name: 'John',
last_name: 'Doe',
birth_date: '1990-01-01'
})
```
---
## Security Best Practices
### ✅ Current Implementation
1. **Secure by Default:** RLS enabled on all tables
2. **Authentication Required:** All policies check `auth.uid() IS NOT NULL`
3. **Separation of Concerns:** Separate policies for each operation (SELECT, INSERT, UPDATE, DELETE)
4. **Audit Trail:** AI events are append-only
5. **Foreign Key Constraints:** Automatic cleanup with CASCADE/SET NULL
6. **Type Safety:** Generated TypeScript types prevent runtime errors
### 🔄 Production Enhancements
When moving to production with multiple organizations:
1. **Add Organization Column:**
```sql
ALTER TABLE clients ADD COLUMN org_id UUID REFERENCES organizations(id);
```
2. **Update Policies with Org Filtering:**
```sql
CREATE POLICY "Users can view own org data"
ON clients FOR SELECT
USING (
auth.uid() IS NOT NULL AND
org_id = (SELECT org_id FROM users WHERE id = auth.uid())
);
```
3. **Add Role-Based Access:**
```sql
CREATE POLICY "Admins can view all"
ON clients FOR SELECT
USING (
auth.uid() IS NOT NULL AND
EXISTS (
SELECT 1 FROM users
WHERE id = auth.uid() AND role IN ('admin', 'superadmin')
)
);
```
4. **Implement Row-Level Ownership:**
```sql
CREATE POLICY "Users can update own records"
ON intake_notes FOR UPDATE
USING (author = auth.uid());
```
---
## Troubleshooting
### Issue: "new row violates row-level security policy"
**Cause:** Trying to insert/update data that doesn't satisfy RLS WITH CHECK
**Solution:** Ensure user is authenticated and data meets policy requirements
### Issue: No data returned despite existing rows
**Cause:** User not authenticated or RLS USING clause filters out all rows
**Solution:** Verify `auth.uid()` returns a valid UUID
### Issue: Service role queries still restricted
**Cause:** Using anon key instead of service role key
**Solution:** Use `SUPABASE_SERVICE_ROLE_KEY` for admin operations
```typescript
// Service role bypasses RLS
const supabase = createClient(url, serviceRoleKey)
```
---
## Migration History
| Migration | Date | Changes |
|-----------|------|---------|
| `20241115000002_create_epd_core_tables.sql` | 2024-11-15 | Initial RLS policies (demo-level) |
| `20241115000003_enhance_rls_policies.sql` | 2024-11-15 | Granular policies per operation + ai_events immutability |
---
## References
- [Supabase RLS Documentation](https://supabase.com/docs/guides/auth/row-level-security)
- [PostgreSQL RLS Documentation](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)
- Technical Design: `docs/specs/to-mini-ecd-v1_2.md` § 2.4
- Build Plan: `docs/specs/bouwplan-ai-speedrun-marketing-first-v1.1.md` Epic 2
---
**Status:** ✅ Implemented and Tested
**Next Steps:** E2.S3 - Demo auth flow

View File

@@ -1,494 +0,0 @@
# Datamodel Mini-ECD: FHIR-compliant GGZ Dossier
**Versie:** 1.1
**Datum:** 21 november 2024
**Status:** In ontwikkeling
---
## Overzicht
Het Mini-ECD gebruikt een datamodel gebaseerd op **FHIR (Fast Healthcare Interoperability Resources)**, de internationale standaard voor uitwisseling van zorggegevens. Dit maakt toekomstige integratie met MedMIJ (patiëntportalen) en Koppeltaal (eHealth apps) mogelijk zonder grote aanpassingen.
Het datamodel bestaat uit **13 kernonderdelen** die samen het complete GGZ-traject ondersteunen: van aanmelding tot behandelplan, inclusief doelen, toestemmingen en belangrijke waarschuwingen.
---
## De 13 bouwstenen van het dossier
### 1. **Behandelaren** (`practitioners`)
**Wat is het?**
Alle zorgprofessionals die in het systeem werken: psychologen, psychiaters, gz-psychologen, verpleegkundigen, etc.
**Belangrijkste gegevens:**
- BIG-nummer (indien geregistreerd)
- AGB-code
- Naam en voorletters
- Kwalificaties (bijv. "GZ-psycholoog", "Psychotherapeut")
- Contactgegevens
**Waarom FHIR?**
In FHIR heet dit een **Practitioner** resource. Dit maakt het mogelijk om behandelaren later uit te wisselen met andere systemen (bijvoorbeeld voor verwijzingen).
---
### 2. **Instellingen** (`organizations`)
**Wat is het?**
De GGZ-organisaties zelf: jouw instelling, maar ook externe organisaties waarmee je samenwerkt.
**Belangrijkste gegevens:**
- AGB-code instelling
- KVK-nummer
- Naam en eventuele nevenvestigingen
- Contactgegevens en adres
**Waarom FHIR?**
In FHIR heet dit een **Organization** resource. Nodig voor facturatie, verwijzingen en juridische verantwoordelijkheid.
---
### 3. **Cliënten** (`patients`)
**Wat is het?**
De patiënten/cliënten die behandeling krijgen.
**Belangrijkste gegevens:**
- BSN (verplicht)
- Naam, geboortedatum, geslacht
- Adres en contactgegevens
- Verzekeringsgegevens
- Huisarts (naam + AGB-code)
- Noodcontactpersoon
**Waarom FHIR?**
In FHIR heet dit een **Patient** resource. Dit is de basis voor alle andere gegevens in het dossier. Het correspondeert met de Nederlandse **ZIB Patient** (ZorgInformatieBouwsteen).
**Privacy:**
BSN wordt versleuteld opgeslagen en is alleen toegankelijk voor geautoriseerde behandelaren.
---
### 4. **Contactmomenten** (`encounters`)
**Wat is het?**
Elk contact tussen cliënt en behandelaar: intakegesprek, behandelsessie, telefonisch consult, etc.
**Belangrijkste gegevens:**
- Type contact (intake, diagnostiek, behandeling, crisis)
- Status (gepland, bezig, afgerond)
- Wanneer (start- en eindtijd)
- Wie (behandelaar + cliënt)
- Waar (polikliniek, online, kliniek)
- Waarom (aanmeldingsreden, klachten)
**Waarom FHIR?**
In FHIR heet dit een **Encounter** resource. Dit is cruciaal omdat alle andere gegevens (diagnoses, observaties, behandelplannen) gekoppeld worden aan een specifiek contactmoment. Hierdoor kun je later zien: "Deze diagnose is gesteld tijdens de intake van 15 maart 2024".
Dit correspondeert met de Nederlandse **ZIB Contact**.
---
### 5. **Diagnoses** (`conditions`)
**Wat is het?**
De vastgestelde diagnoses volgens DSM-5 of ICD-10. Dit kunnen zowel definitieve diagnoses zijn als voorlopige diagnoses.
**Belangrijkste gegevens:**
- DSM-5 code (bijv. "F32.2")
- Omschrijving (bijv. "Depressieve episode, ernstig")
- Status (actief, in remissie, opgelost)
- Ernst (mild, matig, ernstig)
- Zekerheid (voorlopig, bevestigd, uitgesloten)
- Wanneer ontstaan / wanneer opgelost
- Wie stelde de diagnose vast
- Bij welk contactmoment
**Waarom FHIR?**
In FHIR heet dit een **Condition** resource. Dit onderscheidt tussen "encounter diagnosis" (gesteld tijdens een specifiek contact) en "problem list item" (langlopend probleem op de problemlijst).
Dit correspondeert met de Nederlandse **ZIB Problem**.
**Voorbeeld:**
Een cliënt meldt zich aan met depressieve klachten. Na de intake wordt voorlopig "F32.2 - Depressieve episode, ernstig" vastgesteld. Na behandeling verandert de status naar "in remissie".
---
### 6. **Observaties & Metingen** (`observations`)
**Wat is het?**
Alle metingen, scores, risico-inschattingen en observaties tijdens de behandeling.
**Belangrijkste gegevens:**
- Wat werd geobserveerd (bijv. "Suïcidaliteit", "PHQ-9 score", "Bloeddruk")
- Uitkomst (bijv. "Hoog risico", "Score: 18 punten", "120/80")
- Interpretatie (normaal, afwijkend hoog, afwijkend laag)
- Wanneer gemeten
- Door wie
- Bij welk contactmoment
**Categorieën:**
- **ROM-metingen**: PHQ-9, GAD-7, OQ-45, etc.
- **Risico-inschattingen**: Suïcidaliteit, agressie, verwaarlozing
- **Middelengebruik**: Alcohol, drugs, medicatie
- **Vitale functies**: Bloeddruk, hartslag (indien relevant)
- **Sociale anamnese**: Werk, relatie, financiën
**Waarom FHIR?**
In FHIR heet dit een **Observation** resource. Dit is een zeer flexibele resource die allerlei soorten metingen kan bevatten. Door standaard codes te gebruiken (SNOMED, LOINC) kunnen deze later gedeeld worden met andere systemen.
Dit correspondeert met de Nederlandse **ZIB Alert** en **ZIB LaboratoryTestResult**.
**Voorbeeld:**
- ROM-vragenlijst PHQ-9 ingevuld: score 18 (matig-ernstige depressie)
- Risico-inschatting: "Suïcidale gedachten aanwezig, geen concrete plannen" → interpretatie: matig risico
---
### 7. **Medicatie** (`medication_statements`)
**Wat is het?**
De medicatie die de cliënt gebruikt of heeft gebruikt. Dit kan voorgeschreven zijn door de psychiater, maar ook medicatie van de huisarts.
**Belangrijkste gegevens:**
- Medicijnnaam (bijv. "Sertraline 50mg tablet")
- ATC-code (internationale medicijncode)
- Status (actief, gestopt, gepland)
- Dosering (bijv. "1 tablet 's ochtends")
- Toedieningsweg (oraal, intraveneus, etc.)
- Startdatum / stopdatum
- Reden van gebruik (bijv. "Depressie")
**Waarom FHIR?**
In FHIR heet dit een **MedicationStatement** resource. Dit registreert wat de patiënt daadwerkelijk gebruikt (niet wat voorgeschreven is - dat zou een MedicationRequest zijn).
Dit correspondeert met de Nederlandse **ZIB MedicationUse** en is onderdeel van het **MedicatieProces 9.0**.
**Let op:**
Voor volledige medicatiegeschiedenis moet later gekoppeld worden met het Landelijk Schakelpunt (LSP) of andere medicatieservices.
---
### 8. **Behandelplannen** (`care_plans`)
**Wat is het?**
Het overzicht van de geplande behandeling: wat gaan we doen, waarom, en met welk doel?
**Belangrijkste gegevens:**
- Titel (bijv. "Behandelplan depressie")
- Beschrijving van de aanpak
- Status (concept, actief, afgerond, gestopt)
- Looptijd (startdatum - einddatum)
- Behandeldoelen (bijv. "PHQ-9 score < 10", "Herstel dagelijks functioneren")
- Welke diagnoses worden behandeld
- Wie is de regiebehandelaar
- Welk zorgteam is betrokken
**Waarom FHIR?**
In FHIR heet dit een **CarePlan** resource. Dit is de container voor alle behandelactiviteiten en koppelt diagnoses aan interventies.
Dit correspondeert met de Nederlandse **ZIB TreatmentDirective**.
**Koppeltaal-integratie:**
Dit is ook de resource die Koppeltaal gebruikt om eHealth-apps te koppelen aan de behandeling. Bijvoorbeeld: "Opdracht: 3x per week mindfulness oefening via app X".
---
### 9. **Behandelactiviteiten** (`care_plan_activities`)
**Wat is het?**
De concrete activiteiten binnen een behandelplan: gesprekken, medicatie, huiswerkopdrachten, ROM-metingen, etc.
**Belangrijkste gegevens:**
- Omschrijving (bijv. "Individuele CGT sessies", "ROM-meting PHQ-9")
- Status (nog niet gestart, gepland, bezig, afgerond)
- Planning (bijv. "1x per week, 12 sessies")
- Uitvoerende behandelaar
- Locatie (polikliniek, online, kliniek)
- Voortgang (vrije tekst updates)
**Waarom FHIR?**
In FHIR heet dit **CarePlan.activity**. Dit is onderdeel van de CarePlan resource en beschrijft de "wat en wanneer" van de behandeling.
**Voorbeeld activiteiten:**
- Individuele CGT: 1x/week, 12 sessies
- Medicatie: Sertraline 50mg dagelijks
- ROM-meting: Elke 4 weken PHQ-9 invullen
- Huiswerk: Dagboek bijhouden
---
### 10. **Toestemmingen & Wilsverklaringen** (`consents`)
**Wat is het?**
Alle toestemmingen van de cliënt: voor behandeling, voor gegevensuitwisseling (AVG), wilsverklaringen (niet-reanimeren, euthanasie-verklaring, etc.).
**Belangrijkste gegevens:**
- Type toestemming (behandeling, privacy/AVG, wilsverklaring, onderzoek)
- Status (actief, ingetrokken, afgewezen)
- Categorie (niet-reanimeren, advance directive, noodgevallen-only)
- Datum en wie gaf toestemming
- Geldigheid (startdatum - einddatum)
- Wat mag wel/niet (toegang, delen, correctie)
- Met wie mag gedeeld worden (specifieke behandelaren, organisaties)
- Documenten (ondertekende verklaring als PDF)
**Waarom FHIR?**
In FHIR heet dit een **Consent** resource. Dit correspondeert met de Nederlandse **ZIB AdvanceDirective**.
**AVG-compliance:**
Dit is cruciaal voor AVG-naleving. Hiermee registreer je:
- Toestemming voor behandeling (informed consent)
- Toestemming voor delen met huisarts/andere zorgverleners
- Intrekking van toestemming
- Wilsverklaringen die juridisch bindend zijn
**Voorbeelden:**
- "Toestemming behandeling depressie" (informed consent)
- "Geen toestemming delen met huisarts" (privacy)
- "Niet-reanimeren verklaring" (wilsverklaring)
- "Toestemming opname behandelgegevens in landelijke uitwisseling" (MedMIJ)
---
### 11. **Waarschuwingen & Alerts** (`flags`)
**Wat is het?**
Belangrijke waarschuwingen die behandelaren **direct** moeten zien bij het openen van een dossier. Denk aan veiligheidsrisico's, allergieën, of gedragswaarschuwingen.
**Belangrijkste gegevens:**
- Type waarschuwing (veiligheid, klinisch, gedrag, infectie, allergie)
- Alert inhoud (bijv. "Suïciderisico", "Agressie naar hulpverleners")
- Prioriteit (hoog, middel, laag)
- Status (actief, inactief)
- Geldigheid (startdatum - einddatum)
- Wie maakte de alert
- Gerelateerde diagnoses of observaties
**Waarom FHIR?**
In FHIR heet dit een **Flag** resource. Dit correspondeert met de Nederlandse **ZIB Alert**.
**Verschil met Observations:**
Observations zijn metingen/bevindingen. Flags zijn **actieve waarschuwingen** die aandacht vragen.
**Categorieën:**
- **Safety (veiligheid)**: Suïciderisico, zelfverwaarlozing, valrisico
- **Clinical (klinisch)**: Ernstige allergie voor medicatie, infectiegevaar
- **Behavioral (gedrag)**: Agressie naar hulpverleners, grensoverschrijdend gedrag
- **Administrative**: Geen-toon status (privacy), wanbetaler
**Voorbeeld flags:**
- 🔴 "HOOG SUÏCIDERISICO - Concrete plannen, middelen aanwezig"
- 🟠 "Agressie naar vrouwelijke hulpverleners - Alleen mannelijke behandelaar"
- 🟡 "Allergie: Penicilline - anafylactische shock"
- ⚪ "Geen toestemming contact familie - Privacy verzoek"
**In de UI:**
Flags worden prominent weergegeven (rood banner bovenaan dossier) zodat ze niet gemist kunnen worden.
---
### 12. **Documenten** (`document_references`)
**Wat is het?**
Alle documenten in het dossier: intakeverslagen, behandelplannen, brieven aan huisarts, ROM-rapporten, etc.
**Belangrijkste gegevens:**
- Type document (intakeverslag, behandelplan, brief, rapport)
- Status (concept, definitief, vervangen)
- Datum
- Auteur (behandelaar)
- Gekoppeld aan welk contactmoment
- Content (Markdown tekst, PDF, of link naar bestand)
**Waarom FHIR?**
In FHIR heet dit een **DocumentReference** resource. Dit zorgt ervoor dat documenten doorzoekbaar zijn en gekoppeld kunnen worden aan specifieke momenten in de behandeling.
**MedMIJ-integratie:**
Via MedMIJ kunnen cliënten later hun eigen documenten ophalen in een persoonlijke gezondheidsomgeving (PGO-app).
---
## Hoe hangen deze onderdelen samen?
```
Cliënt (Patient)
├─── heeft Toestemmingen (Consents) ⚠️ AVG-compliant
├─── heeft Waarschuwingen (Flags) 🚨 Altijd zichtbaar
└─── heeft Contactmomenten (Encounters)
├─── leidt tot Diagnoses (Conditions)
│ └─── ondersteund door Observaties (Observations)
├─── gebruikt Medicatie (MedicationStatements)
├─── krijgt Behandelplan (CarePlan)
│ ├─── met Doelen (Goals) 🎯 Meetbaar
│ └─── met Activiteiten (CarePlanActivities)
└─── resulteert in Documenten (DocumentReferences)
Uitgevoerd door Behandelaar (Practitioner)
Binnen Instelling (Organization)
```
**Nieuwe verbindingen:**
- **Goals** zijn gekoppeld aan **CarePlan** en **Conditions**
- **Goals** worden gemeten via **Observations** (ROM-scores)
- **Flags** zijn gekoppeld aan **Conditions** en **Observations** (wat veroorzaakt de alert)
- **Consents** bepalen wie **DocumentReferences** mag inzien
---
## Waarom FHIR gebruiken?
### **1. Toekomstbestendig**
FHIR is de internationale standaard voor zorggegevens. Alle moderne zorgsystemen ondersteunen dit. Door vanaf dag 1 FHIR-compliant te bouwen, kunnen we later makkelijk integreren met:
- MedMIJ (patiëntportalen)
- Koppeltaal (eHealth apps)
- Landelijk Schakelpunt (LSP)
- Andere GGZ-instellingen
- Huisartseninformatiesystemen
### **2. Herbruikbaarheid**
Elk onderdeel ("resource") kan apart uitgewisseld worden. Bijvoorbeeld:
- Huisarts vraagt diagnoses op via FHIR API
- Cliënt haalt eigen medicatielijst op via MedMIJ
- eHealth app ontvangt behandelplan via Koppeltaal
### **3. Geen vendor lock-in**
Omdat we een open standaard gebruiken, zijn we niet afhankelijk van één leverancier. Data kan altijd geëxporteerd en geïmporteerd worden in FHIR-formaat.
### **4. Bewezen technologie**
FHIR wordt wereldwijd gebruikt door duizenden ziekenhuizen, klinieken en zorginstellingen. Alle grote EPD-leveranciers ondersteunen het.
---
## MedMIJ & Koppeltaal: Wat betekent dit?
### **MedMIJ - Patiëntportalen**
MedMIJ is het Nederlandse afsprakenstelsel waarmee patiënten hun medische gegevens kunnen ophalen in een PGO-app (Persoonlijke Gezondheidsomgeving).
**Voor GGZ is de "Basisgegevens GGZ 2.0" specificatie relevant:**
- 24 zorginformatiebouwstenen (ZIBs)
- Inclusief: diagnoses, medicatie, behandelplan, contactmomenten
**Ons datamodel ondersteunt dit omdat:**
- Alle velden volgen de MedMIJ FHIR profielen
- DSM-5 codes zijn opgenomen
- Juridische status kan vastgelegd worden
- Medicatie volgens MedicatieProces 9.0
**In de toekomst kunnen we:**
- Een FHIR API bouwen die MedMIJ-compliant is
- Cliënten toegang geven tot hun eigen dossier via een PGO-app
- Automatisch gegevens uitwisselen met andere zorgaanbieders
### **Koppeltaal - eHealth Apps**
Koppeltaal is de standaard waarmee GGZ-instellingen eHealth apps kunnen koppelen aan hun EPD.
**Voorbeeld:**
Behandelaar schrijft voor: "Doe dagelijks de mindfulness oefening in app MindDistrict"
→ Koppeltaal zorgt dat dit automatisch in het EPD en in de app komt te staan
→ Voortgang komt automatisch terug in het EPD
**Ons datamodel ondersteunt dit omdat:**
- CarePlan resource volgt Koppeltaal specificaties
- Activities kunnen gekoppeld worden aan externe apps
- Status updates worden automatisch verwerkt
---
## Privacy & Beveiliging
### **Encryptie**
- BSN wordt versleuteld opgeslagen
- Communicatie via HTTPS/TLS
### **Toegangscontrole (RLS)**
- Behandelaren zien alleen hun eigen cliënten
- Cliënten kunnen later hun eigen data inzien (via patiëntenportaal)
- Auditlog houdt bij wie wat wanneer heeft bekeken
### **AVG-compliance**
- Recht op inzage: cliënt kan eigen data opvragen
- Recht op vergetelheid: data kan verwijderd worden
- Logging: alle acties worden gelogd
- Bewaartermijnen: automatische archivering na X jaar
---
## Technische implementatie
### **Database: PostgreSQL (Supabase)**
- Type-safe met ENUMs voor statussen
- Automatische timestamps (created_at, updated_at)
- Foreign keys voor relaties
- Indexes voor performance
### **Veldnamen volgen FHIR**
Bijvoorbeeld:
- `name_family` → Patient.name.family
- `code_code` → Condition.code.coding.code
- `clinical_status` → Condition.clinicalStatus
Dit maakt het later makkelijk om FHIR JSON te genereren.
### **Later: FHIR API endpoints**
```
GET /fhir/Patient/{id}
GET /fhir/Encounter?patient={id}
GET /fhir/Condition?patient={id}
GET /fhir/CarePlan?patient={id}
```
---
## Wat betekent dit voor gebruikers?
### **Voor behandelaren:**
- Alle data is logisch gestructureerd
- Diagnoses zijn gekoppeld aan intake-moment
- Behandelplan volgt automatisch uit diagnose
- ROM-scores zijn zichtbaar in tijdlijn
### **Voor cliënten (in toekomst):**
- Eigen dossier inzien via app
- Behandelplan en afspraken zien
- ROM-vragenlijsten invullen via app
- Resultaten direct naar behandelaar
### **Voor beheerders:**
- Export naar andere systemen is mogelijk
- Backups bevatten FHIR-compliant data
- Audits en rapportages zijn eenvoudig
- Geen vendor lock-in
---
## Roadmap
### **Fase 1: MVP (nu)**
✅ Database schema met alle FHIR resources
✅ Intake → Diagnose → Behandelplan workflow
✅ Basis toegangscontrole
### **Fase 2: Basis functionaliteit**
🔲 UI voor alle resources
🔲 AI-assistentie voor intake
🔲 ROM-metingen integratie
### **Fase 3: Integraties**
🔲 FHIR API endpoints
🔲 MedMIJ aansluiting (patiëntportaal)
🔲 Koppeltaal aansluiting (eHealth apps)
🔲 LSP medicatie-uitwisseling
---
## Referenties
- **FHIR Specificatie:** https://hl7.org/fhir/
- **MedMIJ GGZ:** https://informatiestandaarden.nictiz.nl/wiki/MedMij:V2020.01/OntwerpGGZ
- **Koppeltaal:** https://www.koppeltaal.nl/
- **ZIBs (ZorgInformatieBouwstenen):** https://zibs.nl/
- **DSM-5 Codes:** American Psychiatric Association
- **MedicatieProces 9.0:** https://informatiestandaarden.nictiz.nl/wiki/mp:V9
---
**Laatst bijgewerkt:** 21 november 2024
**Auteur:** Colin Lit (ikbenlit.nl)
**Project:** AI Speedrun - Mini-ECD

View File

@@ -1,214 +0,0 @@
# 🎨 Design Review: Dot Shader Background Component
**Datum:** 15-11-2024
**Review Team:** Design & UX
**Vraag:** Kan het dot-shader-background component gebruikt worden voor marketingpagina en/of EPD app?
---
## 📋 Context Analyse
### Manifesto Kernwaarden
- **Innovatie & Disruptie:** "AI gaat software eten" - technologische vooruitgang centraal
- **Snelheid:** "4 weken vs 12 maanden" - efficiency en moderniteit
- **Bewijs door doen:** "Beste manier om toekomst te voorspellen is hem bouwen"
- **Digitale transformatie:** Software on Demand als nieuwe realiteit
### UX Stylesheet Principes
- **Lage cognitieve belasting:** Focus op content, niet op decoratie
- **Toegankelijkheid:** WCAG AA contrast, geen afleidende elementen
- **"Less is more":** Accentpalet bewust klein gehouden → minder visuele ruis
- **Functioneel design:** Elke visuele keuze moet doel dienen
### Shader Component Eigenschappen
- **Technisch:** Three.js shader met WebGL rendering
- **Visueel:** Geanimeerde dots met mouse trail interactie
- **Performance:** High-performance mode, GPU-accelerated
- **Theme support:** Dark/light mode compatible
- **Subtiel:** Lage opacity (0.025-0.15), niet dominant
---
## 🎯 Design Team Advies
### ✅ **AANBEVELING: Strategische Inzet**
Het design team adviseert **gecontroleerd gebruik** van het shader component, met duidelijke context-specifieke richtlijnen:
---
## 📍 Marketing Website: **JA, met voorwaarden**
### Waarom het werkt:
1. **Manifesto alignment:** Het component straalt technologische innovatie uit - perfect voor "Software on Demand" messaging
2. **Differentiatie:** Visueel onderscheidend van traditionele SaaS-landing pages
3. **Engagement:** Mouse trail interactie verhoogt tijd op pagina
4. **Credibility:** Toont technische vaardigheid zonder te overdrijven
### Implementatie Richtlijnen:
**Hero Section (Primaire Inzet)**
- ✅ Shader als full-screen achtergrond achter hero content
- ✅ Zeer lage opacity (0.02-0.05) - subtiel, niet dominant
- ✅ Content overlay met sterke contrast (wit op donker, of donker op licht)
- ✅ Performance: Lazy load alleen wanneer hero in viewport
**Secties (Secundaire Inzet)**
- ⚠️ Optioneel in "How it Works" of "Technology" secties
- ❌ NIET in comparison tables, ROI calculator, of formulier secties
- ❌ NIET op mobile (performance + UX overwegingen)
**Technische Aanpassingen Nodig:**
```typescript
// Marketing-specifieke configuratie
const marketingConfig = {
dotOpacity: 0.03, // Zeer subtiel
gridSize: 80, // Fijner grid voor eleganter effect
disableMouseTrail: false, // Interactiviteit behouden
performanceMode: 'high', // GPU optimization
mobileFallback: 'gradient' // Fallback voor mobile
}
```
**Contrast Check:**
- Tekst op shader achtergrond moet voldoen aan WCAG AA (4.5:1)
- Gebruik semi-transparante overlay indien nodig
- Test met verschillende tekstgroottes
---
## 🏥 EPD App: **NEE, tenzij zeer subtiel**
### Waarom het risicovol is:
1. **Cognitieve belasting:** Medische professionals hebben focus nodig - animaties zijn afleidend
2. **UX Stylesheet conflict:** Direct tegenstrijdig met "lage cognitieve belasting" principe
3. **Toegankelijkheid:** Kan problemen veroorzaken voor gebruikers met motion sensitivity
4. **Performance:** Elke milliseconde telt in productie-omgevingen
### Uitzondering: Onboarding/Welcome Screen
-**Alleen** op eerste login/welcome screen
- ✅ Zeer korte duur (3-5 seconden), dan fade-out
- ✅ Optioneel: "Skip animation" knop voor toegankelijkheid
-**NOOIT** tijdens actieve workflows (intake, profiel, behandelplan)
**Implementatie Als Uitzondering:**
```typescript
// EPD-specifieke configuratie (alleen welcome)
const epdWelcomeConfig = {
dotOpacity: 0.01, // Extreem subtiel
gridSize: 120, // Zeer fijn grid
disableMouseTrail: true, // Geen interactiviteit
autoFadeOut: true, // Fade na 3 seconden
skipButton: true, // Toegankelijkheid
reducedMotion: true // Respecteer prefers-reduced-motion
}
```
---
## 🎨 Stylesheet Matching Analyse
### Kleuren Compatibiliteit
**Light Theme Match:**
- ✅ Shader bg: `#F4F5F5` matcht stylesheet `#F8FAFC` (zeer dichtbij)
- ✅ Shader dots: `#e1e1e1` matcht border kleur `#E2E8F0` (harmonisch)
- ⚠️ Aanpassing nodig: Shader moet exact `#F8FAFC` gebruiken voor consistency
**Dark Theme Match:**
- ✅ Shader bg: `#121212` is acceptabel voor dark mode
- ✅ Shader dots: `#FFFFFF` met lage opacity werkt goed
- ⚠️ Stylesheet heeft geen dark mode spec gedefinieerd - dit moet eerst worden uitgewerkt
**Aanbevolen Aanpassingen:**
```typescript
// Update shader theme colors om exact te matchen
const getThemeColors = () => {
switch (theme) {
case 'light':
return {
dotColor: '#E2E8F0', // Match border color
bgColor: '#F8FAFC', // Match app background
dotOpacity: 0.03 // Zeer subtiel voor marketing
}
case 'dark':
return {
dotColor: '#475569', // Match secondary text
bgColor: '#0F172A', // Match primary text (inverted)
dotOpacity: 0.02 // Nog subtieler voor dark
}
}
}
```
---
## ⚖️ "Less is More" vs "Digital Innovation"
### Design Team Consensus:
**Marketing Website:**
- **"Less is more"** geldt voor **content en copy** - niet voor visuele impact
- Shader component kan **strategisch** gebruikt worden om innovatie te communiceren
- **Voorwaarde:** Het moet de boodschap versterken, niet afleiden
- **Test:** A/B test met en zonder shader - meet engagement metrics
**EPD App:**
- **"Less is more"** is hier **absoluut** - elke pixel moet functioneel zijn
- Shader component is **decoratief** en voegt geen functionele waarde toe
- **Uitzondering:** Welcome screen kan één keer indruk maken, daarna weg
---
## 📊 Risico Analyse
| Risico | Kans | Impact | Mitigatie |
|--------|------|--------|-----------|
| **Performance impact** | Medium | Hoog | Lazy loading, mobile fallback, performance monitoring |
| **Toegankelijkheid issues** | Medium | Hoog | `prefers-reduced-motion` respecteren, skip optie |
| **Cognitieve overload** | Hoog | Medium | Zeer lage opacity, alleen hero section |
| **Stylesheet mismatch** | Laag | Laag | Kleuren aanpassen naar exacte stylesheet waarden |
| **Mobile performance** | Hoog | Medium | Automatische fallback naar gradient |
---
## ✅ Finale Aanbeveling
### Marketing Website: **GO** ✅
- Implementeer in hero section met zeer lage opacity
- Pas kleuren aan naar exacte stylesheet waarden
- Voeg mobile fallback toe
- Monitor performance metrics
- Test toegankelijkheid met screen readers
### EPD App: **NO GO** ❌
- **Behalve:** Welcome screen (één keer, met skip optie)
- Focus op functioneel design volgens UX stylesheet
- Gebruik subtiele gradients of solid colors voor achtergronden
### Implementatie Prioriteit:
1. **Week 1:** Marketing hero section met shader (als MVP)
2. **Week 2:** Performance optimalisatie + mobile fallback
3. **Week 4:** A/B test resultaten evalueren
4. **Post-launch:** Beslissing over permanente implementatie
---
## 🎯 Design Principes Samenvatting
**Voor Marketing:**
> "Innovatie moet zichtbaar zijn, maar niet opdringerig"
**Voor EPD:**
> "Elke visuele keuze moet de gebruiker helpen, niet afleiden"
**Algemeen:**
> "Technologie moet de boodschap dienen, niet domineren"
---
**Design Team Sign-off:**
✅ Marketing website: Goedkeuring met voorwaarden
❌ EPD app: Afwijzing (behalve welcome screen)
📝 Stylesheet aanpassingen: Vereist voor consistency

View File

@@ -1,403 +0,0 @@
# 🚀 Marketing Shader Implementation Guide
**Doel:** Praktische implementatie van dot-shader component voor marketing website hero section.
---
## 📐 Design Specificaties
### Hero Section Layout
```
┌─────────────────────────────────────┐
│ [Shader Background - zeer subtiel] │
│ │
│ ┌─────────────────────────────┐ │
│ │ Hero Content Overlay │ │
│ │ - Headline (wit/donker) │ │
│ │ - Subheadline │ │
│ │ - CTA Buttons │ │
│ │ - Live Metrics Counter │ │
│ └─────────────────────────────┘ │
│ │
└─────────────────────────────────────┘
```
### Visuele Hiërarchie
1. **Shader:** Opacity 0.02-0.03 (bijna onzichtbaar, maar aanwezig)
2. **Content Overlay:** Semi-transparant of solid (afhankelijk van contrast)
3. **Tekst:** Hoog contrast (wit op donker, of donker op licht)
---
## 🔧 Technische Implementatie
### Stap 1: Aangepaste Marketing Variant
```typescript
// components/ui/marketing-shader-background.tsx
'use client'
import { DotScreenShader } from './dot-shader-background'
import { useTheme } from 'next-themes'
import { useEffect, useState } from 'react'
interface MarketingShaderProps {
variant?: 'hero' | 'section'
className?: string
}
export function MarketingShader({ variant = 'hero', className }: MarketingShaderProps) {
const { theme } = useTheme()
const [mounted, setMounted] = useState(false)
const [reducedMotion, setReducedMotion] = useState(false)
useEffect(() => {
setMounted(true)
// Check for reduced motion preference
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
setReducedMotion(mediaQuery.matches)
const handleChange = (e: MediaQueryListEvent) => setReducedMotion(e.matches)
mediaQuery.addEventListener('change', handleChange)
return () => mediaQuery.removeEventListener('change', handleChange)
}, [])
// Fallback voor mobile of reduced motion
if (!mounted || reducedMotion) {
return (
<div
className={`absolute inset-0 bg-gradient-to-br from-slate-50 via-blue-50/30 to-slate-100 ${className}`}
aria-hidden="true"
/>
)
}
// Desktop: shader component
return (
<div className={`absolute inset-0 overflow-hidden ${className}`} aria-hidden="true">
<DotScreenShader />
{/* Subtle overlay voor betere tekst leesbaarheid */}
<div className="absolute inset-0 bg-white/40 dark:bg-slate-900/40 pointer-events-none" />
</div>
)
}
```
### Stap 2: Hero Section Component
```typescript
// app/(marketing)/components/hero-section.tsx
import { MarketingShader } from '@/components/ui/marketing-shader-background'
import { LiveMetricsCounter } from './live-metrics-counter'
export function HeroSection() {
return (
<section className="relative min-h-screen flex items-center justify-center overflow-hidden">
{/* Shader Background */}
<MarketingShader variant="hero" className="z-0" />
{/* Content Overlay */}
<div className="relative z-10 container mx-auto px-4 py-20">
<div className="max-w-4xl mx-auto text-center">
{/* Headline */}
<h1 className="text-5xl md:text-7xl font-bold text-slate-900 dark:text-white mb-6">
Software on Demand
</h1>
{/* Subheadline */}
<p className="text-xl md:text-2xl text-slate-700 dark:text-slate-300 mb-8">
Van 100.000 en 12 maanden naar 200 en 4 weken
</p>
{/* Live Metrics */}
<LiveMetricsCounter />
{/* CTAs */}
<div className="flex flex-col sm:flex-row gap-4 justify-center mt-12">
<button className="px-8 py-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition">
Bekijk Demo
</button>
<button className="px-8 py-4 bg-slate-200 text-slate-900 rounded-lg hover:bg-slate-300 transition">
Lees Manifesto
</button>
</div>
</div>
</div>
</section>
)
}
```
### Stap 3: Shader Component Aanpassingen
```typescript
// Aanpassingen in dot-shader-background.tsx voor marketing gebruik
// In Scene component, update getThemeColors:
const getThemeColors = () => {
switch (theme) {
case 'light':
return {
dotColor: '#E2E8F0', // Match UX stylesheet border color
bgColor: '#F8FAFC', // Match UX stylesheet app background
dotOpacity: 0.03 // Zeer subtiel voor marketing
}
case 'dark':
return {
dotColor: '#475569', // Match secondary text
bgColor: '#0F172A', // Match primary text (inverted)
dotOpacity: 0.02 // Nog subtieler
}
default:
return {
dotColor: '#E2E8F0',
bgColor: '#F8FAFC',
dotOpacity: 0.03
}
}
}
// Update gridSize voor eleganter effect
const gridSize = 80 // Fijner dan standaard 100
```
---
## 🎨 Styling Integratie
### Tailwind Classes voor Content Overlay
```css
/* Zorg voor goede contrast over shader */
.hero-content {
/* Optioneel: semi-transparante achtergrond voor tekst */
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(10px);
/* Of: solid achtergrond met padding */
background: white;
padding: 2rem;
border-radius: 1rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
```
### Contrast Check
**Test Scenario's:**
1. Wit tekst op shader achtergrond → Minimaal 4.5:1 contrast
2. Donker tekst op shader achtergrond → Minimaal 4.5:1 contrast
3. Met overlay → Contrast moet verbeteren
**Tools:**
- [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/)
- Browser DevTools → Accessibility panel
---
## 📱 Mobile Responsive
### Breakpoint Strategie
```typescript
// components/ui/marketing-shader-background.tsx
export function MarketingShader({ variant, className }: MarketingShaderProps) {
const [isMobile, setIsMobile] = useState(false)
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 768) // Tailwind md breakpoint
}
checkMobile()
window.addEventListener('resize', checkMobile)
return () => window.removeEventListener('resize', checkMobile)
}, [])
// Mobile: gradient fallback (performance)
if (isMobile) {
return (
<div
className={`absolute inset-0 bg-gradient-to-br from-slate-50 via-blue-50/20 to-slate-100 ${className}`}
aria-hidden="true"
/>
)
}
// Desktop: shader
return <DotScreenShader />
}
```
**Reden:**
- Mobile GPU performance beperkt
- Batterij impact
- UX: Gebruikers verwachten snelle laadtijden op mobile
---
## ⚡ Performance Optimalisatie
### Lazy Loading
```typescript
import dynamic from 'next/dynamic'
// Lazy load shader alleen wanneer hero in viewport
const MarketingShader = dynamic(
() => import('@/components/ui/marketing-shader-background').then(mod => mod.MarketingShader),
{
ssr: false, // Client-side only
loading: () => <div className="absolute inset-0 bg-slate-50" />
}
)
```
### Intersection Observer
```typescript
// components/ui/marketing-shader-background.tsx
export function MarketingShader({ variant, className }: MarketingShaderProps) {
const [shouldRender, setShouldRender] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!ref.current) return
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setShouldRender(true)
observer.disconnect()
}
},
{ threshold: 0.1 }
)
observer.observe(ref.current)
return () => observer.disconnect()
}, [])
if (!shouldRender) {
return <div ref={ref} className={`absolute inset-0 bg-slate-50 ${className}`} />
}
return <DotScreenShader />
}
```
---
## ♿ Toegankelijkheid
### Reduced Motion Support
```typescript
// Automatisch detecteren en respecteren
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
if (prefersReducedMotion) {
// Geen animaties, statische gradient
return <StaticGradientBackground />
}
```
### Skip Animation Knop
```typescript
// Optioneel: knop om animatie uit te zetten
<button
onClick={() => setAnimationEnabled(false)}
className="sr-only focus:not-sr-only"
>
Skip animatie
</button>
```
### ARIA Labels
```typescript
<div
className="absolute inset-0"
aria-hidden="true" // Decoratief element, niet voor screen readers
role="presentation"
>
<DotScreenShader />
</div>
```
---
## 📊 Monitoring & Metrics
### Performance Tracking
```typescript
// Track shader performance
useEffect(() => {
const startTime = performance.now()
// Na render
requestAnimationFrame(() => {
const renderTime = performance.now() - startTime
if (renderTime > 100) {
console.warn('Shader render tijd hoog:', renderTime)
// Mogelijk fallback activeren
}
})
}, [])
```
### A/B Test Setup
```typescript
// Variant A: Met shader
// Variant B: Zonder shader (gradient)
const useShader = () => {
// Cookie/localStorage based variant assignment
const variant = localStorage.getItem('hero-variant') || 'A'
return variant === 'A'
}
```
**Metrics te meten:**
- Time to Interactive (TTI)
- First Contentful Paint (FCP)
- Bounce rate
- Engagement tijd
- Conversion rate
---
## ✅ Checklist
### Pre-Launch
- [ ] Shader opacity op 0.02-0.03 (zeer subtiel)
- [ ] Kleuren matchen exact UX stylesheet
- [ ] Mobile fallback geïmplementeerd
- [ ] Reduced motion support
- [ ] Contrast check gedaan (WCAG AA)
- [ ] Performance test (< 100ms render tijd)
- [ ] Lazy loading geïmplementeerd
### Post-Launch
- [ ] Performance monitoring actief
- [ ] A/B test resultaten analyseren
- [ ] Gebruikersfeedback verzamelen
- [ ] Accessibility audit uitgevoerd
---
## 🎯 Conclusie
Het shader component kan **strategisch** gebruikt worden op de marketing website, mits:
1. Zeer lage opacity (0.02-0.03)
2. Alleen in hero section
3. Mobile fallback aanwezig
4. Toegankelijkheid gewaarborgd
5. Performance geoptimaliseerd
**Resultaat:** Innovatieve uitstraling zonder UX compromissen.

View File

@@ -1,158 +0,0 @@
import Image from "next/image";
import React from "react";
import { Timeline } from "@/components/ui/timeline";
export function TimelineDemo() {
const data = [
{
title: "2024",
content: (
<div>
<p className="text-neutral-800 dark:text-neutral-200 text-xs md:text-sm font-normal mb-8">
Built and launched Aceternity UI and Aceternity UI Pro from scratch
</p>
<div className="grid grid-cols-2 gap-4">
<Image
src="https://assets.aceternity.com/templates/startup-1.webp"
alt="startup template"
width={500}
height={500}
className="rounded-lg object-cover h-20 md:h-44 lg:h-60 w-full shadow-[0_0_24px_rgba(34,_42,_53,_0.06),_0_1px_1px_rgba(0,_0,_0,_0.05),_0_0_0_1px_rgba(34,_42,_53,_0.04),_0_0_4px_rgba(34,_42,_53,_0.08),_0_16px_68px_rgba(47,_48,_55,_0.05),_0_1px_0_rgba(255,_255,_255,_0.1)_inset]"
/>
<Image
src="https://assets.aceternity.com/templates/startup-2.webp"
alt="startup template"
width={500}
height={500}
className="rounded-lg object-cover h-20 md:h-44 lg:h-60 w-full shadow-[0_0_24px_rgba(34,_42,_53,_0.06),_0_1px_1px_rgba(0,_0,_0,_0.05),_0_0_0_1px_rgba(34,_42,_53,_0.04),_0_0_4px_rgba(34,_42,_53,_0.08),_0_16px_68px_rgba(47,_48,_55,_0.05),_0_1px_0_rgba(255,_255,_255,_0.1)_inset]"
/>
<Image
src="https://assets.aceternity.com/templates/startup-3.webp"
alt="startup template"
width={500}
height={500}
className="rounded-lg object-cover h-20 md:h-44 lg:h-60 w-full shadow-[0_0_24px_rgba(34,_42,_53,_0.06),_0_1px_1px_rgba(0,_0,_0,_0.05),_0_0_0_1px_rgba(34,_42,_53,_0.04),_0_0_4px_rgba(34,_42,_53,_0.08),_0_16px_68px_rgba(47,_48,_55,_0.05),_0_1px_0_rgba(255,_255,_255,_0.1)_inset]"
/>
<Image
src="https://assets.aceternity.com/templates/startup-4.webp"
alt="startup template"
width={500}
height={500}
className="rounded-lg object-cover h-20 md:h-44 lg:h-60 w-full shadow-[0_0_24px_rgba(34,_42,_53,_0.06),_0_1px_1px_rgba(0,_0,_0,_0.05),_0_0_0_1px_rgba(34,_42,_53,_0.04),_0_0_4px_rgba(34,_42,_53,_0.08),_0_16px_68px_rgba(47,_48,_55,_0.05),_0_1px_0_rgba(255,_255,_255,_0.1)_inset]"
/>
</div>
</div>
),
},
{
title: "Early 2023",
content: (
<div>
<p className="text-neutral-800 dark:text-neutral-200 text-xs md:text-sm font-normal mb-8">
I usually run out of copy, but when I see content this big, I try to
integrate lorem ipsum.
</p>
<p className="text-neutral-800 dark:text-neutral-200 text-xs md:text-sm font-normal mb-8">
Lorem ipsum is for people who are too lazy to write copy. But we are
not. Here are some more example of beautiful designs I built.
</p>
<div className="grid grid-cols-2 gap-4">
<Image
src="https://assets.aceternity.com/pro/hero-sections.png"
alt="hero template"
width={500}
height={500}
className="rounded-lg object-cover h-20 md:h-44 lg:h-60 w-full shadow-[0_0_24px_rgba(34,_42,_53,_0.06),_0_1px_1px_rgba(0,_0,_0,_0.05),_0_0_0_1px_rgba(34,_42,_53,_0.04),_0_0_4px_rgba(34,_42,_53,_0.08),_0_16px_68px_rgba(47,_48,_55,_0.05),_0_1px_0_rgba(255,_255,_255,_0.1)_inset]"
/>
<Image
src="https://assets.aceternity.com/features-section.png"
alt="feature template"
width={500}
height={500}
className="rounded-lg object-cover h-20 md:h-44 lg:h-60 w-full shadow-[0_0_24px_rgba(34,_42,_53,_0.06),_0_1px_1px_rgba(0,_0,_0,_0.05),_0_0_0_1px_rgba(34,_42,_53,_0.04),_0_0_4px_rgba(34,_42,_53,_0.08),_0_16px_68px_rgba(47,_48,_55,_0.05),_0_1px_0_rgba(255,_255,_255,_0.1)_inset]"
/>
<Image
src="https://assets.aceternity.com/pro/bento-grids.png"
alt="bento template"
width={500}
height={500}
className="rounded-lg object-cover h-20 md:h-44 lg:h-60 w-full shadow-[0_0_24px_rgba(34,_42,_53,_0.06),_0_1px_1px_rgba(0,_0,_0,_0.05),_0_0_0_1px_rgba(34,_42,_53,_0.04),_0_0_4px_rgba(34,_42,_53,_0.08),_0_16px_68px_rgba(47,_48,_55,_0.05),_0_1px_0_rgba(255,_255,_255,_0.1)_inset]"
/>
<Image
src="https://assets.aceternity.com/cards.png"
alt="cards template"
width={500}
height={500}
className="rounded-lg object-cover h-20 md:h-44 lg:h-60 w-full shadow-[0_0_24px_rgba(34,_42,_53,_0.06),_0_1px_1px_rgba(0,_0,_0,_0.05),_0_0_0_1px_rgba(34,_42,_53,_0.04),_0_0_4px_rgba(34,_42,_53,_0.08),_0_16px_68px_rgba(47,_48,_55,_0.05),_0_1px_0_rgba(255,_255,_255,_0.1)_inset]"
/>
</div>
</div>
),
},
{
title: "Changelog",
content: (
<div>
<p className="text-neutral-800 dark:text-neutral-200 text-xs md:text-sm font-normal mb-4">
Deployed 5 new components on Aceternity today
</p>
<div className="mb-8">
<div className="flex gap-2 items-center text-neutral-700 dark:text-neutral-300 text-xs md:text-sm">
Card grid component
</div>
<div className="flex gap-2 items-center text-neutral-700 dark:text-neutral-300 text-xs md:text-sm">
Startup template Aceternity
</div>
<div className="flex gap-2 items-center text-neutral-700 dark:text-neutral-300 text-xs md:text-sm">
Random file upload lol
</div>
<div className="flex gap-2 items-center text-neutral-700 dark:text-neutral-300 text-xs md:text-sm">
Himesh Reshammiya Music CD
</div>
<div className="flex gap-2 items-center text-neutral-700 dark:text-neutral-300 text-xs md:text-sm">
Salman Bhai Fan Club registrations open
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<Image
src="https://assets.aceternity.com/pro/hero-sections.png"
alt="hero template"
width={500}
height={500}
className="rounded-lg object-cover h-20 md:h-44 lg:h-60 w-full shadow-[0_0_24px_rgba(34,_42,_53,_0.06),_0_1px_1px_rgba(0,_0,_0,_0.05),_0_0_0_1px_rgba(34,_42,_53,_0.04),_0_0_4px_rgba(34,_42,_53,_0.08),_0_16px_68px_rgba(47,_48,_55,_0.05),_0_1px_0_rgba(255,_255,_255,_0.1)_inset]"
/>
<Image
src="https://assets.aceternity.com/features-section.png"
alt="feature template"
width={500}
height={500}
className="rounded-lg object-cover h-20 md:h-44 lg:h-60 w-full shadow-[0_0_24px_rgba(34,_42,_53,_0.06),_0_1px_1px_rgba(0,_0,_0,_0.05),_0_0_0_1px_rgba(34,_42,_53,_0.04),_0_0_4px_rgba(34,_42,_53,_0.08),_0_16px_68px_rgba(47,_48,_55,_0.05),_0_1px_0_rgba(255,_255,_255,_0.1)_inset]"
/>
<Image
src="https://assets.aceternity.com/pro/bento-grids.png"
alt="bento template"
width={500}
height={500}
className="rounded-lg object-cover h-20 md:h-44 lg:h-60 w-full shadow-[0_0_24px_rgba(34,_42,_53,_0.06),_0_1px_1px_rgba(0,_0,_0,_0.05),_0_0_0_1px_rgba(34,_42,_53,_0.04),_0_0_4px_rgba(34,_42,_53,_0.08),_0_16px_68px_rgba(47,_48,_55,_0.05),_0_1px_0_rgba(255,_255,_255,_0.1)_inset]"
/>
<Image
src="https://assets.aceternity.com/cards.png"
alt="cards template"
width={500}
height={500}
className="rounded-lg object-cover h-20 md:h-44 lg:h-60 w-full shadow-[0_0_24px_rgba(34,_42,_53,_0.06),_0_1px_1px_rgba(0,_0,_0,_0.05),_0_0_0_1px_rgba(34,_42,_53,_0.04),_0_0_4px_rgba(34,_42,_53,_0.08),_0_16px_68px_rgba(47,_48,_55,_0.05),_0_1px_0_rgba(255,_255,_255,_0.1)_inset]"
/>
</div>
</div>
),
},
];
return (
<div className="min-h-screen w-full">
<div className="absolute top-0 left-0 w-full">
<Timeline data={data} />
</div>
</div>
);
}

View File

@@ -1,185 +0,0 @@
# WCAG AA Compliance Report
**Date:** 2024-11-17
**Project:** Mini-EPD Prototype
**Design System:** Teal-first (v2.1)
---
## Overview
This document outlines the WCAG AA compliance status for the teal-first design system colors. All tested combinations meet or exceed WCAG AA standards for their intended use cases.
## WCAG AA Requirements
- **Normal text** (< 18pt or < 14pt bold): **4.5:1 minimum**
- **Large text** (>= 18pt or >= 14pt bold): **3:1 minimum**
- **UI components** (borders, focus indicators, icons): **3:1 minimum**
---
## Test Results Summary
| Category | Pass Rate | Status |
|----------|-----------|--------|
| Normal text (4.5:1) | 7/11 (64%) | ✅ PASS |
| Large text (3:1) | 11/11 (100%) | ✅ PASS |
| UI components (3:1) | 11/11 (100%) | ✅ PASS |
---
## Color Definitions
### Teal (Brand)
| Shade | Hex | Primary Use | Contrast on White |
|-------|-----|-------------|-------------------|
| teal-600 | `#0D9488` | UI components, buttons (bg) | 3.74:1 ⚠️ Large text only |
| teal-700 | `#0F766E` | **PRIMARY** - Text, links | **5.47:1** ✅ AA Normal |
| teal-800 | `#115E59` | Hover states | Higher contrast |
**Usage Guidelines:**
-**Use teal-700** for body text, headings, links on white/light backgrounds
- ⚠️ **Use teal-600** only for UI components (borders, icons) or large text (>= 18pt)
-**White text on teal-700** passes AA Normal (5.47:1)
### Amber (AI Features)
| Shade | Hex | Primary Use | Contrast on White |
|-------|-----|-------------|-------------------|
| amber-600 | `#D97706` | AI buttons, large text | 3.19:1 ⚠️ Large text only |
| amber-700 | `#B45309` | AI button hover, text | **5.02:1** ✅ AA Normal |
**Usage Guidelines:**
-**Use amber-700** for text on light backgrounds
- ⚠️ **Use amber-600** for buttons with **large text** (>= 18pt) or as gradient start
-**AIButton component** uses amber-600→amber-700 gradient (meets AA for buttons)
---
## Detailed Test Results
### ✅ PASSING (AA Normal Text - 4.5:1)
| Foreground | Background | Ratio | Use Case |
|------------|------------|-------|----------|
| teal-700 | white | **5.47:1** | Primary text, links |
| white | teal-700 | **5.47:1** | Buttons, badges |
| teal-700 | slate-50 | **5.23:1** | Text on gray surfaces |
| teal-700 | teal-50 | **5.25:1** | Text on teal subtle bg |
| white | amber-700 | **5.02:1** | AI button hover |
| amber-700 | amber-50 | **4.84:1** | AI subtle text |
| teal-700 | white | **5.47:1** | Focus rings |
### ⚠️ PASSING (AA Large Text - 3:1)
| Foreground | Background | Ratio | Use Case |
|------------|------------|-------|----------|
| teal-600 | white | 3.74:1 | UI components, large text |
| white | teal-600 | 3.74:1 | Buttons (large text) |
| white | amber-600 | 3.19:1 | AI buttons (large text) |
| amber-600 | white | 3.19:1 | UI components |
---
## Component-Specific Guidelines
### Buttons
```tsx
// ✅ CORRECT: Teal-700 background
<button className="bg-teal-700 text-white">
Primary Action
</button>
// ⚠️ CAUTION: Teal-600 requires large text
<button className="bg-teal-600 text-white text-lg">
Large Button
</button>
// ✅ CORRECT: AIButton uses amber-600→amber-700 gradient
<AIButton>Generate Summary</AIButton>
```
### Text Links
```tsx
// ✅ CORRECT: Teal-700 for links
<a className="text-teal-700 hover:text-teal-800">
Read more
</a>
// ❌ INCORRECT: Teal-600 fails for normal text
<a className="text-teal-600">Fails AA</a>
```
### Focus States
```tsx
// ✅ CORRECT: Teal-700 focus ring
<input className="focus:ring-2 focus:ring-teal-700" />
```
---
## CSS Variables
The following CSS variables have been updated for WCAG AA compliance:
```css
:root {
/* Brand & Primary (Teal-first Design System) */
--color-brand: #0F766E; /* teal-700 - PRIMARY (5.47:1 on white - WCAG AA) */
--color-brand-hover: #115E59; /* teal-800 */
--color-brand-active: #0D9488; /* teal-600 */
/* AI Features (Amber) */
--color-ai: #D97706; /* amber-600 - PRIMARY AI (3.19:1 on white - WCAG AA large) */
--color-ai-hover: #B45309; /* amber-700 */
/* Info color (uses brand) */
--color-info: #0F766E; /* teal-700 - Brand consistency (WCAG AA) */
/* Input focus states */
--color-input-focus: #0F766E; /* teal-700 - Focus states (WCAG AA) */
--color-input-focus-border: #115E59; /* teal-800 */
}
```
---
## Recommendations
### ✅ Current State
- All primary text uses teal-700 (5.47:1 contrast) ✅
- All focus rings use teal-700 (5.47:1 contrast) ✅
- AI buttons use amber-600→amber-700 gradient ✅
- All UI components meet 3:1 minimum ✅
### 📋 Future Enhancements
- Consider using teal-800 for even higher contrast in critical areas
- Monitor user feedback on amber button readability
- Test with color blindness simulators
- Add automated contrast testing to CI/CD pipeline
---
## Testing
Run contrast tests:
```bash
npx tsx scripts/test-contrast.ts
```
## References
- [WCAG 2.1 Guidelines](https://www.w3.org/WAI/WCAG21/quickref/)
- [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/)
- [UX Implementation Plan v2.0](../specs/ux-implementation-plan-v2.md)
---
**Status:** ✅ WCAG AA Compliant
**Last Updated:** 2024-11-17
**Next Review:** Before production launch

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More