feat: migrate clients module to patients + add docs

This commit is contained in:
colinislit
2025-11-23 10:13:00 +01:00
parent 8e3925ea09
commit 6fcb9a0e7b
277 changed files with 9131 additions and 458 deletions

4
.eslintrc.json Normal file
View File

@@ -0,0 +1,4 @@
{
"extends": "next/core-web-vitals"
}

2
.gitignore vendored
View File

@@ -44,3 +44,5 @@ next-env.d.ts
# claude
.claude
.mcp.json
/archive/*

52
CHANGELOG.md Normal file
View File

@@ -0,0 +1,52 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Changed - 2025-11-22
#### Route Consolidatie: `/clients/` → `/patients/`
**Migratie:** Consolideer beide routes naar één FHIR-compliant `/patients/` route met Custom API voor Intakes.
**Wijzigingen:**
- **Routes:** Alle `/epd/clients/*` routes zijn gemigreerd naar `/epd/patients/*`
- `/epd/clients``/epd/patients`
- `/epd/clients/[id]``/epd/patients/[id]`
- `/epd/clients/[id]/intakes``/epd/patients/[id]/intakes`
- `/epd/clients/[id]/intakes/[intakeId]``/epd/patients/[id]/intakes/[intakeId]`
- **Backward Compatibility:**
- Catch-all redirect route toegevoegd: `/epd/clients/[...path]/route.ts`
- Alle oude `/clients/` URLs worden automatisch doorgestuurd naar `/patients/`
- Query parameters worden behouden tijdens redirects
- **Intake Module:**
- Volledige Intake module gemigreerd van `/clients/` naar `/patients/`
- Custom API geïmplementeerd: `/api/intakes`
- Type definitions toegevoegd: `lib/types/intake.ts`
- Server actions refactored naar API-based: `app/epd/patients/[id]/intakes/actions.ts`
- **Code Archive:**
- Oude `/clients/` code gearchiveerd naar `app/epd/_archive/clients_backup_20251122/`
- Alleen redirect routes blijven actief voor backward compatibility
**Breaking Changes:**
- Nieuwe code moet `/epd/patients/` routes gebruiken
- Oude `/epd/clients/` routes werken nog via redirects, maar worden deprecated
**Migration Guide:**
Zie `docs/migratie-clients-naar-patients.md` voor volledige migratie details.
**Files Changed:**
- `app/epd/patients/[id]/intakes/` - Nieuwe Intake module
- `app/epd/clients/[...path]/route.ts` - Catch-all redirect
- `app/epd/clients/page.tsx` - Root redirect
- `lib/types/intake.ts` - Type definitions
- `app/api/intakes/` - Custom API routes
- `docs/specs/UI/bouwplan-mini-epd-v1.0.md` - Route references updated

View File

@@ -0,0 +1,52 @@
import { NextRequest, NextResponse } from 'next/server';
const DEEPGRAM_URL = 'https://api.deepgram.com/v1/listen';
export async function POST(request: NextRequest) {
try {
const apiKey = process.env.DEEPGRAM_API_KEY;
if (!apiKey) {
return NextResponse.json(
{ error: 'DEEPGRAM_API_KEY ontbreekt' },
{ status: 500 }
);
}
const formData = await request.formData();
const file = formData.get('file');
if (!(file instanceof File)) {
return NextResponse.json({ error: 'Bestand ontbreekt' }, { status: 400 });
}
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const response = await fetch(DEEPGRAM_URL, {
method: 'POST',
headers: {
Authorization: `Token ${apiKey}`,
'Content-Type': file.type || 'audio/webm',
},
body: buffer,
});
if (!response.ok) {
const errorText = await response.text();
console.error('Deepgram error', response.status, errorText);
return NextResponse.json(
{ error: 'Deepgram transcribe mislukt', details: errorText },
{ status: 502 }
);
}
const data = await response.json();
const transcript =
data.results?.channels?.[0]?.alternatives?.[0]?.transcript?.trim() || '';
return NextResponse.json({ transcript });
} catch (error) {
console.error('Unexpected error in Deepgram route', error);
return NextResponse.json({ error: 'Onverwachte fout' }, { status: 500 });
}
}

View File

@@ -0,0 +1,203 @@
/**
* Intake Detail API Routes
*
* GET /api/intakes/{intakeId} - Get specific intake
* PUT /api/intakes/{intakeId} - Update intake
* DELETE /api/intakes/{intakeId} - Delete intake
*/
import { createClient } from '@/lib/auth/server';
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
// Validation schema for updates
const UpdateIntakeSchema = z.object({
title: z.string().min(1).optional(),
department: z.enum(['Volwassenen', 'Jeugd', 'Ouderen']).optional(),
status: z.enum(['bezig', 'afgerond']).optional(),
start_date: z.string().optional(),
end_date: z.string().nullable().optional(),
psychologist_id: z.string().uuid().nullable().optional(),
notes: z.string().nullable().optional(),
});
/**
* GET /api/intakes/{intakeId}
* Get a specific intake by ID
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ intakeId: string }> }
) {
try {
const { intakeId } = await params;
// Validate UUID format
if (!z.string().uuid().safeParse(intakeId).success) {
return NextResponse.json(
{ error: 'intakeId moet een geldige UUID zijn' },
{ status: 400 }
);
}
const supabase = await createClient();
const { data, error } = await supabase
.from('intakes')
.select('*')
.eq('id', intakeId)
.single();
if (error) {
// Check if it's a not found error
if (error.code === 'PGRST116') {
return NextResponse.json(
{ error: 'Intake niet gevonden' },
{ status: 404 }
);
}
console.error('Error fetching intake:', error);
return NextResponse.json(
{ error: 'Fout bij ophalen intake', details: error.message },
{ status: 500 }
);
}
return NextResponse.json(data);
} catch (error) {
console.error('Unexpected error in GET /api/intakes/[intakeId]:', error);
return NextResponse.json(
{ error: 'Onverwachte serverfout' },
{ status: 500 }
);
}
}
/**
* PUT /api/intakes/{intakeId}
* Update an existing intake
*/
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ intakeId: string }> }
) {
try {
const { intakeId } = await params;
// Validate UUID format
if (!z.string().uuid().safeParse(intakeId).success) {
return NextResponse.json(
{ error: 'intakeId moet een geldige UUID zijn' },
{ status: 400 }
);
}
const body = await request.json();
// Validate input
const result = UpdateIntakeSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{
error: 'Validatiefout',
details: result.error.errors.map(e => ({
field: e.path.join('.'),
message: e.message,
})),
},
{ status: 400 }
);
}
const supabase = await createClient();
// Update intake
const { data, error } = await supabase
.from('intakes')
.update(result.data)
.eq('id', intakeId)
.select()
.single();
if (error) {
// Check if it's a not found error
if (error.code === 'PGRST116') {
return NextResponse.json(
{ error: 'Intake niet gevonden' },
{ status: 404 }
);
}
console.error('Error updating intake:', error);
return NextResponse.json(
{ error: 'Fout bij bijwerken intake', details: error.message },
{ status: 500 }
);
}
return NextResponse.json(data);
} catch (error) {
console.error('Unexpected error in PUT /api/intakes/[intakeId]:', error);
// Handle JSON parse errors
if (error instanceof SyntaxError) {
return NextResponse.json(
{ error: 'Ongeldige JSON in request body' },
{ status: 400 }
);
}
return NextResponse.json(
{ error: 'Onverwachte serverfout' },
{ status: 500 }
);
}
}
/**
* DELETE /api/intakes/{intakeId}
* Delete an intake
*/
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ intakeId: string }> }
) {
try {
const { intakeId } = await params;
// Validate UUID format
if (!z.string().uuid().safeParse(intakeId).success) {
return NextResponse.json(
{ error: 'intakeId moet een geldige UUID zijn' },
{ status: 400 }
);
}
const supabase = await createClient();
// Delete intake
const { error } = await supabase
.from('intakes')
.delete()
.eq('id', intakeId);
if (error) {
console.error('Error deleting intake:', error);
return NextResponse.json(
{ error: 'Fout bij verwijderen intake', details: error.message },
{ status: 500 }
);
}
// Return 204 No Content on successful deletion
return new NextResponse(null, { status: 204 });
} catch (error) {
console.error('Unexpected error in DELETE /api/intakes/[intakeId]:', error);
return NextResponse.json(
{ error: 'Onverwachte serverfout' },
{ status: 500 }
);
}
}

151
app/api/intakes/route.ts Normal file
View File

@@ -0,0 +1,151 @@
/**
* Intakes API Routes
*
* GET /api/intakes?patientId={id} - List all intakes for a patient
* POST /api/intakes - Create new intake
*/
import { createClient } from '@/lib/auth/server';
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
// Validation schemas
const CreateIntakeSchema = z.object({
patient_id: z.string().uuid('Patient ID moet een geldige UUID zijn'),
title: z.string().min(1, 'Titel is verplicht'),
department: z.enum(['Volwassenen', 'Jeugd', 'Ouderen'], {
errorMap: () => ({ message: 'Afdeling moet Volwassenen, Jeugd of Ouderen zijn' }),
}),
start_date: z.string().min(1, 'Startdatum is verplicht'),
psychologist_id: z.string().uuid().optional(),
notes: z.string().optional(),
});
/**
* GET /api/intakes?patientId={id}
* List all intakes for a specific patient
*/
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const patientId = searchParams.get('patientId');
if (!patientId) {
return NextResponse.json(
{ error: 'patientId query parameter is verplicht' },
{ status: 400 }
);
}
// Validate UUID format
if (!z.string().uuid().safeParse(patientId).success) {
return NextResponse.json(
{ error: 'patientId moet een geldige UUID zijn' },
{ status: 400 }
);
}
const supabase = await createClient();
const { data, error } = await supabase
.from('intakes')
.select('*')
.eq('patient_id', patientId)
.order('start_date', { ascending: false });
if (error) {
console.error('Error fetching intakes:', error);
return NextResponse.json(
{ error: 'Fout bij ophalen intakes', details: error.message },
{ status: 500 }
);
}
return NextResponse.json({
intakes: data,
total: data.length,
});
} catch (error) {
console.error('Unexpected error in GET /api/intakes:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
const errorStack = error instanceof Error ? error.stack : undefined;
return NextResponse.json(
{
error: 'Onverwachte serverfout',
details: errorMessage,
...(process.env.NODE_ENV === 'development' && { stack: errorStack }),
},
{ status: 500 }
);
}
}
/**
* POST /api/intakes
* Create a new intake
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// Validate input
const result = CreateIntakeSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{
error: 'Validatiefout',
details: result.error.errors.map(e => ({
field: e.path.join('.'),
message: e.message,
})),
},
{ status: 400 }
);
}
const { patient_id, title, department, start_date, psychologist_id, notes } = result.data;
const supabase = await createClient();
// Create intake
const { data, error } = await supabase
.from('intakes')
.insert({
patient_id,
title,
department,
start_date,
psychologist_id,
notes,
status: 'bezig', // Default status in database enum
})
.select()
.single();
if (error) {
console.error('Error creating intake:', error);
return NextResponse.json(
{ error: 'Fout bij aanmaken intake', details: error.message },
{ status: 500 }
);
}
return NextResponse.json(data, { status: 201 });
} catch (error) {
console.error('Unexpected error in POST /api/intakes:', error);
// Handle JSON parse errors
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

@@ -0,0 +1,121 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { createClient } from '@/lib/auth/server';
const createSchema = z.object({
activity_text: z
.string()
.min(3, 'Beschrijf minimaal 3 tekens')
.max(2000, 'Maximaal 2000 tekens'),
});
async function getPractitionerForUser(supabase: Awaited<ReturnType<typeof createClient>>, userId?: string) {
if (!userId) return null;
const { data } = await supabase
.from('practitioners')
.select('id, name_given, name_family')
.eq('user_id', userId)
.maybeSingle();
return data;
}
function formatPractitionerName(practitioner: any) {
if (!practitioner) return null;
const given = (practitioner.name_given || []).join(' ');
const family = practitioner.name_family || '';
return `${given} ${family}`.trim();
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ screeningId: string }> }
) {
try {
const { screeningId } = await params;
const supabase = await createClient();
const { data, error } = await supabase
.from('screening_activities')
.select('*')
.eq('screening_id', screeningId)
.order('created_at', { ascending: false });
if (error) {
console.error('Error fetching activities:', error);
return NextResponse.json(
{ error: 'Fout bij ophalen activiteiten', details: error.message },
{ status: 500 }
);
}
return NextResponse.json({ activities: data || [] });
} catch (error) {
console.error('Unexpected error in GET /api/screenings/[screeningId]/activities:', error);
const message = error instanceof Error ? error.message : 'Unknown error';
return NextResponse.json(
{ error: 'Onverwachte serverfout', details: message },
{ status: 500 }
);
}
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ screeningId: string }> }
) {
try {
const body = await request.json();
const parsed = createSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validatiefout', details: parsed.error.flatten().fieldErrors },
{ status: 400 }
);
}
const { screeningId } = await params;
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Niet geautoriseerd' }, { status: 401 });
}
const practitioner = await getPractitionerForUser(supabase, user.id);
const displayName = formatPractitionerName(practitioner) || user.user_metadata?.full_name || user.email || 'Onbekende gebruiker';
const { data, error } = await supabase
.from('screening_activities')
.insert({
screening_id: screeningId,
activity_text: parsed.data.activity_text,
created_by: practitioner?.id || null,
created_by_name: displayName,
})
.select('*')
.single();
if (error) {
console.error('Error creating activity:', error);
return NextResponse.json(
{ error: 'Fout bij toevoegen activiteit', details: error.message },
{ status: 500 }
);
}
return NextResponse.json({ activity: data }, { status: 201 });
} catch (error) {
console.error('Unexpected error in POST /api/screenings/[screeningId]/activities:', error);
const message = error instanceof Error ? error.message : 'Unknown error';
return NextResponse.json(
{ error: 'Onverwachte serverfout', details: message },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,69 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/auth/server';
import { supabaseAdmin } from '@/lib/supabase/server';
const BUCKET = 'screening-documents';
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ screeningId: string; documentId: string }> }
) {
try {
const { documentId } = await params;
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Niet geautoriseerd' }, { status: 401 });
}
const { data: document, error } = await supabase
.from('screening_documents')
.select('*')
.eq('id', documentId)
.maybeSingle();
if (error) {
console.error('Fetch document error:', error);
return NextResponse.json(
{ error: 'Document niet gevonden', details: error.message },
{ status: 500 }
);
}
if (!document) {
return NextResponse.json({ error: 'Document niet gevonden' }, { status: 404 });
}
const { error: deleteError } = await supabaseAdmin.storage
.from(BUCKET)
.remove([document.file_path]);
if (deleteError) {
console.error('Storage delete error:', deleteError);
// Don't stop here; attempt DB delete even if storage failed
}
const { error: dbError } = await supabase
.from('screening_documents')
.delete()
.eq('id', documentId);
if (dbError) {
console.error('DB delete error:', dbError);
return NextResponse.json(
{ error: 'Verwijderen mislukt', details: dbError.message },
{ status: 500 }
);
}
return NextResponse.json({ success: true });
} catch (error) {
console.error('Unexpected error in DELETE /documents/[documentId]:', error);
const message = error instanceof Error ? error.message : 'Unknown error';
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,128 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/auth/server';
import { supabaseAdmin } from '@/lib/supabase/server';
const BUCKET = 'screening-documents';
async function ensureBucket() {
const { data, error } = await supabaseAdmin.storage.getBucket(BUCKET);
if (!data) {
const { error: createError } = await supabaseAdmin.storage.createBucket(BUCKET, {
public: true,
fileSizeLimit: 50 * 1024 * 1024, // 50 MB
});
if (createError && !createError.message.includes('already exists')) {
throw createError;
}
} else if (error && !error.message.includes('not found')) {
throw error;
}
}
async function getPractitionerByUser(supabase: Awaited<ReturnType<typeof createClient>>, userId?: string) {
if (!userId) return null;
const { data } = await supabase
.from('practitioners')
.select('id, name_given, name_family')
.eq('user_id', userId)
.maybeSingle();
return data;
}
function formatDisplayName(practitioner: any, fallback: string) {
if (practitioner) {
const given = (practitioner.name_given || []).join(' ');
const family = practitioner.name_family || '';
return `${given} ${family}`.trim();
}
return fallback;
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ screeningId: string }> }
) {
try {
const { screeningId } = await params;
const supabase = await createClient();
const {
data: { user },
error: userError,
} = await supabase.auth.getUser();
if (userError || !user) {
return NextResponse.json({ error: 'Niet geautoriseerd' }, { status: 401 });
}
const formData = await request.formData();
const file = formData.get('file');
if (!(file instanceof File)) {
return NextResponse.json({ error: 'Bestand ontbreekt' }, { status: 400 });
}
const documentType = (formData.get('documentType') as string) || 'overig';
await ensureBucket();
const filePath = `${screeningId}/${Date.now()}-${file.name}`;
const arrayBuffer = await file.arrayBuffer();
const { error: uploadError } = await supabaseAdmin.storage
.from(BUCKET)
.upload(filePath, Buffer.from(arrayBuffer), {
contentType: file.type,
cacheControl: '3600',
upsert: false,
});
if (uploadError) {
console.error('Upload error:', uploadError);
return NextResponse.json(
{ error: 'Upload mislukt', details: uploadError.message },
{ status: 500 }
);
}
const practitioner = await getPractitionerByUser(supabase, user.id);
const displayName = formatDisplayName(
practitioner,
(user.user_metadata?.full_name as string) || user.email || 'Onbekend'
);
const { data, error } = await supabase
.from('screening_documents')
.insert({
screening_id: screeningId,
file_name: file.name,
file_type: file.type,
file_size: file.size,
file_path: filePath,
document_type: documentType,
uploaded_by: practitioner?.id || null,
uploaded_by_name: displayName,
})
.select('*')
.single();
if (error) {
console.error('DB insert error:', error);
return NextResponse.json(
{ error: 'Opslaan mislukt', details: error.message },
{ status: 500 }
);
}
const { data: publicUrlData } = supabaseAdmin.storage.from(BUCKET).getPublicUrl(filePath);
return NextResponse.json({
document: {
...data,
publicUrl: publicUrlData.publicUrl,
},
});
} catch (error) {
console.error('Unexpected error in POST /documents:', error);
const message = error instanceof Error ? error.message : 'Unknown error';
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,177 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { createClient } from '@/lib/auth/server';
const updateSchema = z.object({
request_for_help: z.string().optional(),
decision: z.enum(['geschikt', 'niet_geschikt']).optional(),
decision_notes: z.string().optional(),
decision_department: z.string().optional(),
});
function serializeScreening(screening: any) {
const activities = [...(screening.screening_activities || [])].sort((a, b) => {
const aTime = a.created_at ? new Date(a.created_at).getTime() : 0;
const bTime = b.created_at ? new Date(b.created_at).getTime() : 0;
return bTime - aTime;
});
const documents = [...(screening.screening_documents || [])].sort((a, b) => {
const aTime = a.uploaded_at ? new Date(a.uploaded_at).getTime() : 0;
const bTime = b.uploaded_at ? new Date(b.uploaded_at).getTime() : 0;
return bTime - aTime;
});
return {
...screening,
screening_activities: activities,
screening_documents: documents,
};
}
async function getPractitionerForUser(supabase: Awaited<ReturnType<typeof createClient>>, userId?: string) {
if (!userId) return null;
const { data } = await supabase
.from('practitioners')
.select('id, name_given, name_family')
.eq('user_id', userId)
.maybeSingle();
return data;
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ screeningId: string }> }
) {
try {
const { screeningId } = await params;
const supabase = await createClient();
const { data, error } = await supabase
.from('screenings')
.select('*, screening_activities(*), screening_documents(*)')
.eq('id', screeningId)
.maybeSingle();
if (error) {
console.error('Error fetching screening:', error);
return NextResponse.json(
{ error: 'Fout bij ophalen screening', details: error.message },
{ status: 500 }
);
}
if (!data) {
return NextResponse.json({ error: 'Screening niet gevonden' }, { status: 404 });
}
return NextResponse.json({ screening: serializeScreening(data) });
} catch (error) {
console.error('Unexpected error in GET /api/screenings/[screeningId]:', error);
const message = error instanceof Error ? error.message : 'Unknown error';
return NextResponse.json(
{ error: 'Onverwachte serverfout', details: message },
{ status: 500 }
);
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ screeningId: string }> }
) {
try {
const body = await request.json();
const parsed = updateSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validatiefout', details: parsed.error.flatten().fieldErrors },
{ status: 400 }
);
}
const { screeningId } = await params;
const supabase = await createClient();
const updates: Record<string, unknown> = {};
if (Object.prototype.hasOwnProperty.call(parsed.data, 'request_for_help')) {
updates.request_for_help = parsed.data.request_for_help || null;
}
if (Object.prototype.hasOwnProperty.call(parsed.data, 'decision_notes')) {
updates.decision_notes = parsed.data.decision_notes || null;
}
if (Object.prototype.hasOwnProperty.call(parsed.data, 'decision_department')) {
updates.decision_department = parsed.data.decision_department || null;
}
if (Object.prototype.hasOwnProperty.call(parsed.data, 'decision')) {
const decision = parsed.data.decision;
if (decision === 'geschikt' && !parsed.data.decision_department) {
return NextResponse.json(
{ error: 'Afdeling is verplicht bij een positief besluit' },
{ status: 400 }
);
}
const {
data: { user },
} = await supabase.auth.getUser();
const practitioner = await getPractitionerForUser(supabase, user?.id);
updates.decision = decision;
updates.decision_date = new Date().toISOString();
updates.decision_by = practitioner?.id || null;
}
if (Object.keys(updates).length === 0) {
return NextResponse.json(
{ error: 'Geen velden om bij te werken ontvangen' },
{ status: 400 }
);
}
const { data, error } = await supabase
.from('screenings')
.update(updates)
.eq('id', screeningId)
.select('*, screening_activities(*), screening_documents(*)')
.single();
if (error) {
console.error('Error updating screening:', error);
return NextResponse.json(
{ error: 'Fout bij bijwerken screening', details: error.message },
{ status: 500 }
);
}
if (parsed.data.decision) {
const newStatus = parsed.data.decision === 'geschikt' ? 'active' : 'cancelled';
const { error: statusError } = await supabase
.from('patients')
.update({ status: newStatus })
.eq('id', data.patient_id);
if (statusError) {
console.error('Error updating patient status:', statusError);
}
}
return NextResponse.json({ screening: serializeScreening(data) });
} catch (error) {
console.error('Unexpected error in PUT /api/screenings/[screeningId]:', error);
const message = error instanceof Error ? error.message : 'Unknown error';
return NextResponse.json(
{ error: 'Onverwachte serverfout', details: message },
{ status: 500 }
);
}
}

127
app/api/screenings/route.ts Normal file
View File

@@ -0,0 +1,127 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { createClient } from '@/lib/auth/server';
const querySchema = z.object({
patientId: z.string().uuid('patientId moet een geldige UUID zijn'),
});
const createSchema = z.object({
patient_id: z.string().uuid('patient_id moet een geldige UUID zijn'),
});
function serializeScreening(screening: any) {
const activities = [...(screening.screening_activities || [])].sort((a, b) => {
const aTime = a.created_at ? new Date(a.created_at).getTime() : 0;
const bTime = b.created_at ? new Date(b.created_at).getTime() : 0;
return bTime - aTime;
});
const documents = [...(screening.screening_documents || [])].sort((a, b) => {
const aTime = a.uploaded_at ? new Date(a.uploaded_at).getTime() : 0;
const bTime = b.uploaded_at ? new Date(b.uploaded_at).getTime() : 0;
return bTime - aTime;
});
return {
...screening,
screening_activities: activities,
screening_documents: documents,
};
}
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const parsed = querySchema.safeParse({ patientId: searchParams.get('patientId') });
if (!parsed.success) {
return NextResponse.json(
{ error: 'patientId query parameter is verplicht' },
{ status: 400 }
);
}
const supabase = await createClient();
const { patientId } = parsed.data;
const { data, error } = await supabase
.from('screenings')
.select('*, screening_activities(*), screening_documents(*)')
.eq('patient_id', patientId)
.maybeSingle();
if (error && error.code !== 'PGRST116') {
console.error('Error fetching screening:', error);
return NextResponse.json(
{ error: 'Fout bij ophalen screening', details: error.message },
{ status: 500 }
);
}
if (!data) {
const { data: created, error: insertError } = await supabase
.from('screenings')
.insert({ patient_id: patientId })
.select('*, screening_activities(*), screening_documents(*)')
.single();
if (insertError) {
console.error('Error creating screening:', insertError);
return NextResponse.json(
{ error: 'Fout bij aanmaken screening', details: insertError.message },
{ status: 500 }
);
}
return NextResponse.json({ screening: serializeScreening(created) });
}
return NextResponse.json({ screening: serializeScreening(data) });
} catch (error) {
console.error('Unexpected error in GET /api/screenings:', error);
const message = error instanceof Error ? error.message : 'Unknown error';
return NextResponse.json(
{ error: 'Onverwachte serverfout', details: message },
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const parsed = createSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validatiefout', details: parsed.error.flatten().fieldErrors },
{ status: 400 }
);
}
const supabase = await createClient();
const { data, error } = await supabase
.from('screenings')
.insert({ patient_id: parsed.data.patient_id })
.select('*, screening_activities(*), screening_documents(*)')
.single();
if (error) {
console.error('Error creating screening:', error);
return NextResponse.json(
{ error: 'Fout bij aanmaken screening', details: error.message },
{ status: 500 }
);
}
return NextResponse.json({ screening: serializeScreening(data) }, { status: 201 });
} catch (error) {
console.error('Unexpected error in POST /api/screenings:', error);
const message = error instanceof Error ? error.message : 'Unknown error';
return NextResponse.json(
{ error: 'Onverwachte serverfout', details: message },
{ status: 500 }
);
}
}

View File

@@ -15,7 +15,7 @@ export async function GET(request: NextRequest) {
const token = requestUrl.searchParams.get('token') // Fallback for older templates
const token_hash = requestUrl.searchParams.get('token_hash') // Supabase uses this for email links
const type = requestUrl.searchParams.get('type') // recovery, signup, etc
const next = requestUrl.searchParams.get('next') ?? '/epd/clients'
const next = requestUrl.searchParams.get('next') ?? '/epd/patients'
// Debug logging - ALWAYS log to diagnose redirect issues
console.log('🔍 Auth Callback Debug:', {

View File

@@ -0,0 +1,31 @@
import { redirect } from 'next/navigation';
import { NextRequest } from 'next/server';
/**
* Catch-all Redirect Route for /epd/clients/
*
* Redirects all /epd/clients/* routes to /epd/patients/* for backward compatibility
* This ensures that old bookmarks and links continue to work after migration.
*
* Preserves query parameters (e.g., ?tab=intake&search=test)
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> }
) {
const { path } = await params;
const searchParams = request.nextUrl.searchParams;
// Build new path with query parameters
const newPath = `/epd/patients/${path.join('/')}`;
const newUrl = new URL(newPath, request.url);
// Preserve all query parameters
searchParams.forEach((value, key) => {
newUrl.searchParams.set(key, value);
});
redirect(newUrl.toString());
}

View File

@@ -0,0 +1,10 @@
import { redirect } from 'next/navigation';
/**
* Clients Root Redirect
* Redirects /epd/clients to /epd/patients for backward compatibility
*/
export default function ClientsRedirect() {
redirect('/epd/patients');
}

View File

@@ -0,0 +1,31 @@
import { redirect } from 'next/navigation';
import { NextRequest } from 'next/server';
/**
* Catch-all Redirect Route for /epd/clients/
*
* Redirects all /epd/clients/* routes to /epd/patients/* for backward compatibility
* This ensures that old bookmarks and links continue to work after migration.
*
* Preserves query parameters (e.g., ?tab=intake&search=test)
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> }
) {
const { path } = await params;
const searchParams = request.nextUrl.searchParams;
// Build new path with query parameters
const newPath = `/epd/patients/${path.join('/')}`;
const newUrl = new URL(newPath, request.url);
// Preserve all query parameters
searchParams.forEach((value, key) => {
newUrl.searchParams.set(key, value);
});
redirect(newUrl.toString());
}

View File

@@ -1,57 +1,10 @@
import { Suspense } from 'react';
import { Plus } from 'lucide-react';
import { getClients } from './actions';
import { ClientList } from './components/client-list';
import { ClientListSkeleton } from './components/client-list-skeleton';
import Link from 'next/link';
import { redirect } from 'next/navigation';
interface SearchParams {
search?: string;
sortBy?: 'name' | 'age' | 'created_at';
sortOrder?: 'asc' | 'desc';
}
export default async function ClientsPage({
searchParams,
}: {
searchParams: Promise<SearchParams>;
}) {
const params = await searchParams;
return (
<div className="px-4 sm:px-6 lg:px-8 py-8">
{/* Page Header */}
<div className="mb-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900">Cliënten</h1>
<p className="text-sm text-slate-600 mt-1">
Beheer uw cliëntenbestand
</p>
</div>
<Link
href="/epd/clients/new"
className="inline-flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-teal-600 to-teal-700 hover:from-teal-700 hover:to-teal-800 text-white font-medium rounded-lg shadow-sm hover:shadow-md transition-all"
>
<Plus className="h-4 w-4" />
<span>Nieuwe cliënt</span>
</Link>
</div>
</div>
{/* Client List with Suspense */}
<Suspense fallback={<ClientListSkeleton />}>
<ClientListWrapper searchParams={params} />
</Suspense>
</div>
);
}
async function ClientListWrapper({ searchParams }: { searchParams: SearchParams }) {
const clients = await getClients({
search: searchParams.search,
sortBy: searchParams.sortBy,
sortOrder: searchParams.sortOrder,
});
return <ClientList initialClients={clients} />;
/**
* Clients Root Redirect
* Redirects /epd/clients to /epd/patients for backward compatibility
*/
export default function ClientsRedirect() {
redirect('/epd/patients');
}

View File

@@ -2,53 +2,47 @@
import React, { useState, useEffect } from 'react';
import { Search, ChevronDown } from 'lucide-react';
import { usePathname } from 'next/navigation';
import { getClient } from '../clients/actions';
import { getPatient } from '../patients/actions';
import type { FHIRPatient } from '@/lib/fhir';
interface EPDHeaderProps {
className?: string;
}
interface ClientData {
id: string;
first_name: string;
last_name: string;
birth_date: string;
}
export function EPDHeader({ className = "" }: EPDHeaderProps) {
const pathname = usePathname();
const [selectedClient, setSelectedClient] = useState<ClientData | null>(null);
const [selectedPatient, setSelectedPatient] = useState<FHIRPatient | null>(null);
const [isLoading, setIsLoading] = useState(false);
// Context detection: Level 2 if URL contains /clients/[id]
const isClientContext = pathname.match(/\/epd\/clients\/[^\/]+/);
const clientId = isClientContext ? pathname.split('/')[3] : null;
// Context detection: Level 2 if URL contains /patients/[id]
const isPatientContext = pathname.match(/\/epd\/patients\/[^\/]+/);
const patientId = isPatientContext ? pathname.split('/')[3] : null;
useEffect(() => {
async function fetchClient() {
if (!clientId) {
setSelectedClient(null);
async function fetchPatient() {
if (!patientId) {
setSelectedPatient(null);
return;
}
// Don't re-fetch if we already have the correct client
if (selectedClient?.id === clientId) return;
// Don't re-fetch if we already have the correct patient
if (selectedPatient?.id === patientId) return;
setIsLoading(true);
try {
const client = await getClient(clientId);
if (client) {
setSelectedClient(client);
const patient = await getPatient(patientId);
if (patient) {
setSelectedPatient(patient);
}
} catch (error) {
console.error('Failed to fetch client for header:', error);
console.error('Failed to fetch patient for header:', error);
} finally {
setIsLoading(false);
}
}
fetchClient();
}, [clientId, selectedClient?.id]);
fetchPatient();
}, [patientId, selectedPatient?.id]);
return (
<header className={`h-[60px] bg-white border-b border-slate-200 flex items-center px-6 ${className}`}>
@@ -57,22 +51,22 @@ export function EPDHeader({ className = "" }: EPDHeaderProps) {
<span className="text-base font-medium text-slate-800">Mini-ECD</span>
</div>
{/* Center: Client Selector (only in Level 2) */}
{/* Center: Patient Selector (only in Level 2) */}
<div className="flex-1 flex justify-center">
{(selectedClient || isLoading) && clientId && (
{(selectedPatient || isLoading) && patientId && (
<button className="flex flex-col items-center px-4 py-1 hover:bg-slate-50 rounded-md transition-colors group">
{isLoading ? (
<div className="h-8 w-32 bg-slate-100 animate-pulse rounded" />
) : selectedClient ? (
) : selectedPatient ? (
<>
<div className="flex items-center gap-1.5">
<span className="text-sm font-medium text-slate-900">
{selectedClient.first_name} {selectedClient.last_name}
{selectedPatient.name?.[0]?.given?.join(' ') || ''} {selectedPatient.name?.[0]?.family || ''}
</span>
<ChevronDown className="h-4 w-4 text-slate-400 group-hover:text-slate-600 transition-colors" />
</div>
<span className="text-xs text-slate-500">
ID: {selectedClient.id.substring(0, 8)}... | Geb: {new Date(selectedClient.birth_date).toLocaleDateString('nl-NL')}
ID: {selectedPatient.id?.substring(0, 8) || ''}... | Geb: {selectedPatient.birthDate ? new Date(selectedPatient.birthDate).toLocaleDateString('nl-NL') : ''}
</span>
</>
) : null}
@@ -86,7 +80,7 @@ export function EPDHeader({ className = "" }: EPDHeaderProps) {
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-slate-400" />
<input
type="text"
placeholder="Zoek cliënt..."
placeholder="Zoek patiënt..."
className="w-64 pl-9 pr-4 py-2 bg-slate-50 border border-slate-200 rounded-md text-sm placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent transition-all duration-200"
/>
</div>

View File

@@ -12,7 +12,12 @@ import {
ChevronRight,
FileText,
HelpCircle,
LayoutDashboard
LayoutDashboard,
User,
ClipboardList,
Stethoscope,
Calendar,
FileBarChart
} from 'lucide-react';
interface NavigationItem {
@@ -32,18 +37,20 @@ interface EPDSidebarProps {
// LEVEL 1: Behandelaar Context Navigation
const level1NavigationItems: NavigationItem[] = [
{ id: "dashboard", name: "Dashboard", icon: LayoutDashboard, href: "/epd/dashboard" },
{ id: "clients", name: "Cliënten", icon: Users, href: "/epd/clients" },
{ id: "clients", name: "Cliënten", icon: Users, href: "/epd/patients" },
{ id: "agenda", name: "Agenda", icon: FileText, href: "/epd/agenda" },
{ id: "reports", name: "Rapportage", icon: Settings, href: "/epd/reports" },
];
// LEVEL 2: Client Dossier Context Navigation (clientId gets injected)
const level2NavigationItems: NavigationItem[] = [
{ id: "dashboard", name: "Dashboard", icon: LayoutDashboard, href: "/dashboard" },
{ id: "intake", name: "Intake", icon: FileText, href: "/intake" },
{ id: "diagnose", name: "Diagnose", icon: Settings, href: "/diagnose" },
{ id: "plan", name: "Behandelplan", icon: HelpCircle, href: "/plan" },
{ id: "reports", name: "Rapportage", icon: Settings, href: "/reports" },
{ id: "dashboard", name: "Dashboard", icon: LayoutDashboard, href: "" },
{ id: "basisgegevens", name: "Basisgegevens", icon: User, href: "/basisgegevens" },
{ id: "screening", name: "Screening", icon: ClipboardList, href: "/screening" },
{ id: "intake", name: "Intake", icon: FileText, href: "/intakes" },
{ id: "diagnose", name: "Diagnose", icon: Stethoscope, href: "/diagnose" },
{ id: "behandelplan", name: "Behandelplan", icon: Calendar, href: "/behandelplan" },
{ id: "rapportage", name: "Rapportage", icon: FileBarChart, href: "/rapportage" },
];
export function EPDSidebar({ className = "", userEmail, userName }: EPDSidebarProps) {
@@ -51,15 +58,15 @@ export function EPDSidebar({ className = "", userEmail, userName }: EPDSidebarPr
const [isOpen, setIsOpen] = useState(false);
const [isCollapsed, setIsCollapsed] = useState(false);
// Context detection: Level 2 if URL contains /clients/[id]
const isClientContext = pathname.match(/\/epd\/clients\/[^\/]+/);
const clientId = isClientContext ? pathname.split('/')[3] : null;
// Context detection: Level 2 if URL contains /patients/[id]
const isPatientContext = pathname.match(/\/epd\/patients\/[^\/]+/);
const patientId = isPatientContext ? pathname.split('/')[3] : null;
// Determine which navigation items to show
const navigationItems = isClientContext
const navigationItems = isPatientContext
? level2NavigationItems.map(item => ({
...item,
href: `/epd/clients/${clientId}${item.href}`
href: `/epd/patients/${patientId}${item.href}`
}))
: level1NavigationItems;
@@ -161,10 +168,10 @@ export function EPDSidebar({ className = "", userEmail, userName }: EPDSidebarPr
{/* Navigation */}
<nav className="flex-1 px-3 py-4 overflow-y-auto">
{/* Back to Cliënten button (Level 2 only) */}
{isClientContext && (
{isPatientContext && (
<>
<Link
href="/epd/clients"
href="/epd/patients"
className="flex items-center gap-2 px-3 py-2.5 mb-2 text-sm font-medium text-slate-600 hover:text-slate-900 hover:bg-slate-50 rounded-md transition-colors"
>
<ChevronLeft className="h-4 w-4" />
@@ -177,7 +184,11 @@ export function EPDSidebar({ className = "", userEmail, userName }: EPDSidebarPr
<ul className="space-y-1">
{navigationItems.map((item) => {
const Icon = item.icon;
const isActive = pathname === item.href || pathname.startsWith(item.href + '/');
// For Dashboard (empty suffix), only match exact path
// For others, match exact or any sub-route
const isActive = item.id === 'dashboard'
? pathname === item.href
: pathname === item.href || (item.href && pathname.startsWith(item.href + '/'));
return (
<li key={item.id}>

View File

@@ -1,10 +1,11 @@
/**
* Basisgegevens Page
* E2.S3: Patient basic information with edit functionality
* E3.S3: Patient basic information with edit and delete functionality
*/
import { getPatient } from '../../actions';
import { PatientForm } from '../../components/patient-form';
import { DeletePatientButton } from '../../components/delete-patient-button';
import { AlertCircle } from 'lucide-react';
export default async function BasisgegevensPage({
@@ -15,6 +16,16 @@ export default async function BasisgegevensPage({
const { id } = await params;
const patient = await getPatient(id);
// Get patient name for delete confirmation
const patientName = patient.name?.[0]
? [
...(patient.name[0].given || []),
patient.name[0].family,
]
.filter(Boolean)
.join(' ')
: 'deze patiënt';
// Check if John Doe
const isJohnDoe = (patient as any).extension?.find(
(ext: any) => ext.url === 'http://mini-epd.local/fhir/StructureDefinition/john-doe'
@@ -28,14 +39,6 @@ export default async function BasisgegevensPage({
return (
<div className="p-6">
{/* Page Header */}
<div className="mb-6">
<h2 className="text-lg font-semibold text-slate-900">Basisgegevens</h2>
<p className="text-sm text-slate-600 mt-1">
NAW-gegevens en contactinformatie
</p>
</div>
{/* John Doe Warning */}
{isJohnDoe && hasMissingBSN && (
<div className="bg-orange-50 border border-orange-200 rounded-lg p-4 mb-6">
@@ -56,6 +59,9 @@ export default async function BasisgegevensPage({
{/* Patient Form */}
<div className="bg-white rounded-lg border border-slate-200 p-6">
<PatientForm patient={patient} />
{/* Delete Patient Button */}
<DeletePatientButton patientId={id} patientName={patientName} />
</div>
</div>
);

View File

@@ -50,7 +50,7 @@ export function ClientSidebar({ patientId }: ClientSidebarProps) {
},
{
label: 'Intake',
href: `/epd/patients/${patientId}/intake`,
href: `/epd/patients/${patientId}/intakes`,
icon: FileText,
},
{
@@ -87,7 +87,10 @@ export function ClientSidebar({ patientId }: ClientSidebarProps) {
<nav className="flex-1 p-4 space-y-1">
{navItems.map((item) => {
const Icon = item.icon;
const isActive = pathname === item.href;
// Special handling for Intake tab: active when on /intakes or /intakes/[id]
const isActive = item.href.includes('/intakes')
? pathname.startsWith(`/epd/patients/${patientId}/intakes`)
: pathname === item.href;
return (
<Link

View File

@@ -1,41 +1,15 @@
import { redirect } from 'next/navigation';
/**
* Intake Page
* E2.S3: Placeholder for intake functionality (to be implemented in Epic 4)
* Intake Redirect
* Redirects from /epd/patients/[id]/intake to /epd/patients/[id]/intakes
*/
import { FileText } from 'lucide-react';
export default async function IntakePage({
export default async function IntakeRedirect({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return (
<div className="p-6">
{/* Page Header */}
<div className="mb-6">
<h2 className="text-lg font-semibold text-slate-900">Intake</h2>
<p className="text-sm text-slate-600 mt-1">
Overzicht van intakes en intake details
</p>
</div>
{/* Placeholder */}
<div className="bg-white rounded-lg border border-slate-200 p-12 text-center">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-teal-50 mb-4">
<FileText className="h-8 w-8 text-teal-500" />
</div>
<h3 className="text-lg font-semibold text-slate-900 mb-2">
Intake Module - Coming Soon
</h3>
<p className="text-sm text-slate-600 max-w-md mx-auto">
De intake functionaliteit wordt geïmplementeerd in Epic 4 en 5. Dit omvat
intake overzicht, contactmomenten, kindcheck, risicotaxatie, anamnese,
onderzoek en ROM-metingen.
</p>
</div>
</div>
);
redirect(`/epd/patients/${id}/intakes`);
}

View File

@@ -0,0 +1,415 @@
'use server';
import { revalidatePath } from 'next/cache';
import { createClient } from '@/lib/auth/server';
import type { Database } from '@/lib/supabase/database.types';
export type Encounter = Database['public']['Tables']['encounters']['Row'];
export type RiskAssessment = Database['public']['Tables']['risk_assessments']['Row'];
export type Anamnese = Database['public']['Tables']['anamneses']['Row'];
export type Examination = Database['public']['Tables']['examinations']['Row'];
export type Condition = Database['public']['Tables']['conditions']['Row'];
export type KindcheckData = {
hasChildren?: boolean;
childCount?: number;
ages?: string;
concerns?: boolean;
concernsNotes?: string;
actionTaken?: boolean;
actionNotes?: string;
notes?: string;
};
function buildPath(patientId: string, intakeId: string, tab?: string) {
const base = `/epd/patients/${patientId}/intakes/${intakeId}`;
return tab ? `${base}/${tab}` : base;
}
async function getSupabase() {
return createClient();
}
// ---------------- Contacts ----------------
export async function getContactMoments(intakeId: string) {
const supabase = await getSupabase();
const { data, error } = await supabase
.from('encounters')
.select('*')
.eq('intake_id', intakeId)
.order('period_start', { ascending: false });
if (error) {
console.error('getContactMoments error', error);
throw new Error('Kon contactmomenten niet ophalen');
}
return data || [];
}
export interface ContactPayload {
patientId: string;
intakeId: string;
date: string;
startTime: string;
endTime?: string;
type: string;
location?: string;
notes?: string;
}
export async function createContactMoment(input: ContactPayload) {
const supabase = await getSupabase();
const startIso = new Date(`${input.date}T${input.startTime}:00`).toISOString();
const endIso = input.endTime ? new Date(`${input.date}T${input.endTime}:00`).toISOString() : null;
const { error } = await supabase.from('encounters').insert({
patient_id: input.patientId,
intake_id: input.intakeId,
class_code: input.location || 'AMB',
class_display: input.location || 'Onbekend',
status: 'finished',
type_code: input.type,
type_display: input.type,
period_start: startIso,
period_end: endIso,
notes: input.notes,
});
if (error) {
console.error('createContactMoment error', error);
throw new Error(error.message);
}
revalidatePath(buildPath(input.patientId, input.intakeId, 'contacts'));
revalidatePath(buildPath(input.patientId, input.intakeId));
}
export async function deleteContactMoment(patientId: string, intakeId: string, encounterId: string) {
const supabase = await getSupabase();
const { error } = await supabase.from('encounters').delete().eq('id', encounterId);
if (error) {
console.error('deleteContactMoment error', error);
throw new Error(error.message);
}
revalidatePath(buildPath(patientId, intakeId, 'contacts'));
revalidatePath(buildPath(patientId, intakeId));
}
// ---------------- Kindcheck ----------------
export async function getKindcheck(intakeId: string): Promise<KindcheckData> {
const supabase = await getSupabase();
const { data, error } = await supabase
.from('intakes')
.select('kindcheck_data')
.eq('id', intakeId)
.maybeSingle();
if (error) {
console.error('getKindcheck error', error);
throw new Error('Kon kindcheck niet ophalen');
}
return (data?.kindcheck_data as KindcheckData) || {};
}
export async function saveKindcheck(
patientId: string,
intakeId: string,
payload: KindcheckData
) {
const supabase = await getSupabase();
const { error } = await supabase
.from('intakes')
.update({ kindcheck_data: payload })
.eq('id', intakeId);
if (error) {
console.error('saveKindcheck error', error);
throw new Error(error.message);
}
revalidatePath(buildPath(patientId, intakeId, 'kindcheck'));
revalidatePath(buildPath(patientId, intakeId));
}
// ---------------- Risks ----------------
export async function getRiskAssessments(intakeId: string) {
const supabase = await getSupabase();
const { data, error } = await supabase
.from('risk_assessments')
.select('*')
.eq('intake_id', intakeId)
.order('assessment_date', { ascending: false });
if (error) {
console.error('getRiskAssessments error', error);
throw new Error('Kon risicotaxaties niet ophalen');
}
return data || [];
}
export interface RiskPayload {
patientId: string;
intakeId: string;
date: string;
type: string;
level: string;
rationale: string;
measures?: string;
evaluationDate?: string;
notes?: string;
}
export async function createRiskAssessment(payload: RiskPayload) {
const supabase = await getSupabase();
const { error } = await supabase.from('risk_assessments').insert({
intake_id: payload.intakeId,
assessment_date: payload.date,
risk_type: payload.type,
risk_level: payload.level,
rationale: payload.rationale,
measures: payload.measures,
evaluation_date: payload.evaluationDate || null,
notes: payload.notes,
});
if (error) {
console.error('createRiskAssessment error', error);
throw new Error(error.message);
}
revalidatePath(buildPath(payload.patientId, payload.intakeId, 'risk'));
revalidatePath(buildPath(payload.patientId, payload.intakeId));
}
export async function deleteRiskAssessment(patientId: string, intakeId: string, riskId: string) {
const supabase = await getSupabase();
const { error } = await supabase.from('risk_assessments').delete().eq('id', riskId);
if (error) {
console.error('deleteRiskAssessment error', error);
throw new Error(error.message);
}
revalidatePath(buildPath(patientId, intakeId, 'risk'));
revalidatePath(buildPath(patientId, intakeId));
}
// ---------------- Anamneses ----------------
export async function getAnamneses(intakeId: string) {
const supabase = await getSupabase();
const { data, error } = await supabase
.from('anamneses')
.select('*')
.eq('intake_id', intakeId)
.order('anamnese_date', { ascending: false });
if (error) {
console.error('getAnamneses error', error);
throw new Error('Kon anamneses niet ophalen');
}
return data || [];
}
export interface AnamnesePayload {
patientId: string;
intakeId: string;
date: string;
type: string;
content: string;
notes?: string;
}
export async function createAnamnese(payload: AnamnesePayload) {
const supabase = await getSupabase();
const { error } = await supabase.from('anamneses').insert({
intake_id: payload.intakeId,
anamnese_date: payload.date,
anamnese_type: payload.type,
content: payload.content,
notes: payload.notes,
});
if (error) {
console.error('createAnamnese error', error);
throw new Error(error.message);
}
revalidatePath(buildPath(payload.patientId, payload.intakeId, 'anamnese'));
revalidatePath(buildPath(payload.patientId, payload.intakeId));
}
export async function deleteAnamnese(patientId: string, intakeId: string, anamneseId: string) {
const supabase = await getSupabase();
const { error } = await supabase.from('anamneses').delete().eq('id', anamneseId);
if (error) {
console.error('deleteAnamnese error', error);
throw new Error(error.message);
}
revalidatePath(buildPath(patientId, intakeId, 'anamnese'));
revalidatePath(buildPath(patientId, intakeId));
}
// ---------------- Examinations (including ROM) ----------------
export async function getExaminations(intakeId: string) {
const supabase = await getSupabase();
const { data, error } = await supabase
.from('examinations')
.select('*')
.eq('intake_id', intakeId)
.order('examination_date', { ascending: false });
if (error) {
console.error('getExaminations error', error);
throw new Error('Kon onderzoeken niet ophalen');
}
return data || [];
}
export interface ExaminationPayload {
patientId: string;
intakeId: string;
date: string;
type: string;
findings: string;
performer?: string;
reason?: string;
notes?: string;
isRom?: boolean;
}
export async function createExamination(payload: ExaminationPayload) {
const supabase = await getSupabase();
const { error } = await supabase.from('examinations').insert({
intake_id: payload.intakeId,
examination_date: payload.date,
examination_type: payload.isRom ? 'ROM' : payload.type,
findings: payload.findings,
performed_by: payload.performer,
reason: payload.reason,
notes: payload.notes,
});
if (error) {
console.error('createExamination error', error);
throw new Error(error.message);
}
const tab = payload.isRom ? 'rom' : 'examination';
revalidatePath(buildPath(payload.patientId, payload.intakeId, tab));
revalidatePath(buildPath(payload.patientId, payload.intakeId));
}
export async function deleteExamination(patientId: string, intakeId: string, examinationId: string) {
const supabase = await getSupabase();
const { error } = await supabase.from('examinations').delete().eq('id', examinationId);
if (error) {
console.error('deleteExamination error', error);
throw new Error(error.message);
}
revalidatePath(buildPath(patientId, intakeId, 'examination'));
revalidatePath(buildPath(patientId, intakeId, 'rom'));
revalidatePath(buildPath(patientId, intakeId));
}
// ---------------- Diagnoses ----------------
export async function getDiagnoses(intakeId: string) {
const supabase = await getSupabase();
const { data, error } = await supabase
.from('conditions')
.select('*')
.eq('encounter_id', intakeId)
.order('recorded_date', { ascending: false });
if (error) {
console.error('getDiagnoses error', error);
throw new Error('Kon diagnoses niet ophalen');
}
return data || [];
}
export interface DiagnosisPayload {
patientId: string;
intakeId: string;
code: string;
description: string;
severity?: string;
status?: string;
notes?: string;
}
export async function createDiagnosis(payload: DiagnosisPayload) {
const supabase = await getSupabase();
const { error } = await supabase.from('conditions').insert({
patient_id: payload.patientId,
encounter_id: payload.intakeId,
code_code: payload.code,
code_display: payload.description,
code_system: 'DSM-5',
clinical_status: payload.status || 'active',
severity_display: payload.severity || null,
note: payload.notes,
recorded_date: new Date().toISOString(),
});
if (error) {
console.error('createDiagnosis error', error);
throw new Error(error.message);
}
revalidatePath(buildPath(payload.patientId, payload.intakeId, 'diagnosis'));
revalidatePath(buildPath(payload.patientId, payload.intakeId));
}
export async function deleteDiagnosis(patientId: string, intakeId: string, diagnosisId: string) {
const supabase = await getSupabase();
const { error } = await supabase.from('conditions').delete().eq('id', diagnosisId);
if (error) {
console.error('deleteDiagnosis error', error);
throw new Error(error.message);
}
revalidatePath(buildPath(patientId, intakeId, 'diagnosis'));
revalidatePath(buildPath(patientId, intakeId));
}
// ---------------- Treatment Advice ----------------
export async function getTreatmentAdvice(intakeId: string) {
const supabase = await getSupabase();
const { data, error } = await supabase
.from('intakes')
.select('treatment_advice')
.eq('id', intakeId)
.maybeSingle();
if (error) {
console.error('getTreatmentAdvice error', error);
throw new Error('Kon behandeladvies niet ophalen');
}
return data?.treatment_advice || {};
}
export interface TreatmentAdvicePayload {
patientId: string;
intakeId: string;
advice: string;
department?: string;
program?: string;
notes?: string;
psychologist?: string;
finalize?: boolean;
outcome?: 'in_zorg' | 'doorverwijzing' | 'extra_diagnostiek';
outcomeNotes?: string;
}
export async function saveTreatmentAdvice(payload: TreatmentAdvicePayload) {
const supabase = await getSupabase();
const updates: Record<string, unknown> = {
treatment_advice: {
advice: payload.advice,
department: payload.department,
program: payload.program,
notes: payload.notes,
psychologist: payload.psychologist,
outcome: payload.outcome,
outcomeNotes: payload.outcomeNotes,
updatedAt: new Date().toISOString(),
},
};
if (payload.finalize) {
updates.status = 'afgerond';
updates.end_date = new Date().toISOString().split('T')[0];
}
const { error } = await supabase
.from('intakes')
.update(updates)
.eq('id', payload.intakeId);
if (error) {
console.error('saveTreatmentAdvice error', error);
throw new Error(error.message);
}
revalidatePath(buildPath(payload.patientId, payload.intakeId, 'behandeladvies'));
revalidatePath(buildPath(payload.patientId, payload.intakeId));
}

View File

@@ -0,0 +1,143 @@
'use client';
import { useState, useTransition } from 'react';
import { createAnamnese, deleteAnamnese, type Anamnese } from '../../actions';
import { Loader2, Trash2 } from 'lucide-react';
import { format } from 'date-fns';
import { nl } from 'date-fns/locale';
const types = [
'Psychiatrische anamnese',
'Sociale anamnese',
'Medische anamnese',
'Familieanamnese',
'Ontwikkelingsanamnese',
'Overig',
];
interface AnamneseManagerProps {
patientId: string;
intakeId: string;
anamneses: Anamnese[];
}
export function AnamneseManager({ patientId, intakeId, anamneses }: AnamneseManagerProps) {
const [form, setForm] = useState({
date: '',
type: types[0],
content: '',
notes: '',
});
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const [deletingId, setDeletingId] = useState<string | null>(null);
const handleSubmit = () => {
if (!form.date || !form.content) {
setError('Datum en inhoud zijn verplicht.');
return;
}
setError(null);
startTransition(async () => {
try {
await createAnamnese({
patientId,
intakeId,
date: form.date,
type: form.type,
content: form.content,
notes: form.notes,
});
setForm({ ...form, content: '', notes: '' });
} catch (err) {
setError(err instanceof Error ? err.message : 'Opslaan mislukt');
}
});
};
const handleDelete = (id: string) => {
setDeletingId(id);
startTransition(async () => {
try {
await deleteAnamnese(patientId, intakeId, id);
} catch (err) {
setError(err instanceof Error ? err.message : 'Verwijderen mislukt');
} finally {
setDeletingId(null);
}
});
};
return (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-3">
{anamneses.length === 0 && <p className="text-sm text-slate-500">Nog geen anamneses.</p>}
{anamneses.map((item) => (
<div key={item.id} className="rounded-lg border border-slate-200 p-4 space-y-1">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-slate-900">{item.anamnese_type}</p>
<p className="text-xs text-slate-500">
{format(new Date(item.anamnese_date), 'd MMM yyyy', { locale: nl })}
</p>
</div>
<button
type="button"
onClick={() => handleDelete(item.id)}
disabled={deletingId === item.id}
className="inline-flex items-center gap-1 rounded-md border border-red-200 px-3 py-1.5 text-xs text-red-600"
>
{deletingId === item.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />} Verwijder
</button>
</div>
<p className="text-sm text-slate-700 whitespace-pre-line">{item.content}</p>
{item.notes && <p className="text-xs text-slate-500">Notities: {item.notes}</p>}
</div>
))}
</div>
<div className="rounded-lg border border-slate-200 p-4 space-y-3">
<h3 className="text-sm font-semibold text-slate-900">Nieuwe anamnese</h3>
<input
type="date"
value={form.date}
onChange={(e) => setForm((prev) => ({ ...prev, date: e.target.value }))}
className="h-10 rounded-md border border-slate-300 px-3 text-sm"
/>
<select
value={form.type}
onChange={(e) => setForm((prev) => ({ ...prev, type: e.target.value }))}
className="h-10 rounded-md border border-slate-300 px-3 text-sm"
>
{types.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
<textarea
value={form.content}
onChange={(e) => setForm((prev) => ({ ...prev, content: e.target.value }))}
placeholder="Inhoud"
className="w-full rounded-md border border-slate-300 px-3 py-2 text-sm"
/>
<textarea
value={form.notes}
onChange={(e) => setForm((prev) => ({ ...prev, notes: e.target.value }))}
placeholder="Notities"
className="w-full rounded-md border border-slate-300 px-3 py-2 text-sm"
/>
{error && <p className="text-sm text-red-600">{error}</p>}
<button
type="button"
onClick={handleSubmit}
disabled={isPending}
className="inline-flex items-center gap-2 rounded-md bg-teal-600 px-4 py-2 text-sm font-medium text-white"
>
{isPending && <Loader2 className="h-4 w-4 animate-spin" />}
Opslaan
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,23 @@
import { getAnamneses } from '../actions';
import { AnamneseManager } from './components/anamnese-manager';
export default async function IntakeAnamnesePage({
params,
}: {
params: Promise<{ id: string; intakeId: string }>;
}) {
const { id, intakeId } = await params;
const anamneses = await getAnamneses(intakeId);
return (
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold text-slate-900">Anamnese</h2>
<p className="text-sm text-slate-600">
Vastleggen van psychiatrische, sociale en andere anamneses.
</p>
</div>
<AnamneseManager patientId={id} intakeId={intakeId} anamneses={anamneses} />
</div>
);
}

View File

@@ -0,0 +1,130 @@
'use client';
import { useEffect, useRef, useState, useCallback } from 'react';
import { Loader2, Mic, Square } from 'lucide-react';
interface SpeechRecorderProps {
onTranscript: (text: string) => void;
}
export function SpeechRecorder({ onTranscript }: SpeechRecorderProps) {
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const [isRecording, setIsRecording] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [permissionDenied, setPermissionDenied] = useState(false);
useEffect(() => {
return () => {
mediaRecorderRef.current?.stream.getTracks().forEach((track) => track.stop());
};
}, []);
const stopStream = () => {
mediaRecorderRef.current?.stream.getTracks().forEach((track) => track.stop());
mediaRecorderRef.current = null;
};
const handleStop = useCallback(async () => {
setIsRecording(false);
const chunks = chunksRef.current;
chunksRef.current = [];
stopStream();
if (chunks.length === 0) return;
const blob = new Blob(chunks, { type: 'audio/webm' });
const formData = new FormData();
formData.append('file', blob, 'recording.webm');
try {
setIsUploading(true);
setError(null);
const response = await fetch('/api/deepgram/transcribe', {
method: 'POST',
body: formData,
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.error || 'Transcriptie mislukt');
}
const data = await response.json();
if (data.transcript) {
onTranscript(data.transcript as string);
}
} catch (err) {
console.error(err);
setError(err instanceof Error ? err.message : 'Onbekende fout');
} finally {
setIsUploading(false);
}
}, [onTranscript]);
const startRecording = async () => {
try {
setError(null);
setPermissionDenied(false);
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm' });
mediaRecorderRef.current = mediaRecorder;
chunksRef.current = [];
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
chunksRef.current.push(event.data);
}
};
mediaRecorder.onstop = handleStop;
mediaRecorder.start();
setIsRecording(true);
} catch (err) {
console.error(err);
setPermissionDenied(true);
setError('Toegang tot microfoon geweigerd of niet beschikbaar.');
}
};
const stopRecording = () => {
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop();
}
};
const isBusy = isRecording || isUploading;
return (
<div className="rounded-lg border border-slate-200 p-4 bg-white space-y-2">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-semibold text-slate-900">Spraak-naar-tekst</p>
<p className="text-xs text-slate-500">
{isRecording ? 'Opname loopt…' : 'Neem een fragment op en laat Deepgram transcriberen.'}
</p>
</div>
<button
type="button"
onClick={isRecording ? stopRecording : startRecording}
disabled={isUploading}
className="inline-flex items-center gap-2 rounded-md border border-slate-300 px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-50 disabled:opacity-60"
>
{isRecording ? <><Square className="h-3 w-3 text-red-600" /> Stop</> : <><Mic className="h-3 w-3 text-teal-600" /> Opnemen</>}
</button>
</div>
{isUploading && (
<p className="text-xs text-slate-500 inline-flex items-center gap-1">
<Loader2 className="h-3 w-3 animate-spin" /> Transcriptie bezig
</p>
)}
{permissionDenied && (
<p className="text-xs text-red-600">Microfoontoegang nodig om op te nemen.</p>
)}
{error && !permissionDenied && (
<p className="text-xs text-red-600">{error}</p>
)}
</div>
);
}

View File

@@ -0,0 +1,245 @@
'use client';
import { useState, useTransition } from 'react';
import { saveTreatmentAdvice } from '../../actions';
import { Loader2, Calendar, UserCircle, ClipboardList, Share2, CheckCircle2 } from 'lucide-react';
import Link from 'next/link';
import { RichTextEditor } from '@/components/rich-text-editor';
import { SpeechRecorder } from './speech-recorder';
interface AdviceData {
advice?: string;
department?: string;
program?: string;
notes?: string;
psychologist?: string;
outcome?: 'in_zorg' | 'doorverwijzing' | 'extra_diagnostiek';
outcomeNotes?: string;
}
const departments = ['Blijft huidige afdeling', 'Volwassenen', 'Jeugd', 'Forensisch'];
const programs = ['Algemeen GGZ', 'FACT', 'Verslaving', 'Trauma'];
const outcomeOptions = [
{ value: 'in_zorg', label: 'Cliënt gaat in zorg' },
{ value: 'doorverwijzing', label: 'Doorverwijzen' },
{ value: 'extra_diagnostiek', label: 'Extra diagnostiek nodig' },
];
const DEFAULT_PLACEHOLDER = `- Aanbevolen behandelvorm…
- Frequentie en duur…
- Aanvullende interventies…
- Medicatie-overleg indien relevant…
- Monitoring en evaluatie…`;
interface TreatmentAdviceFormProps {
patientId: string;
intakeId: string;
initialData: AdviceData;
initialDate: string;
initialPsychologist?: string;
}
export function TreatmentAdviceForm({ patientId, intakeId, initialData, initialDate, initialPsychologist }: TreatmentAdviceFormProps) {
const [form, setForm] = useState<AdviceData>({
advice: initialData.advice || '',
department: initialData.department || '',
program: initialData.program || '',
notes: initialData.notes || '',
psychologist: initialData.psychologist || initialPsychologist || '',
outcome: initialData.outcome,
outcomeNotes: initialData.outcomeNotes || '',
});
const [finalize, setFinalize] = useState(Boolean(initialData.outcome));
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const appendTranscript = (text: string) => {
if (!text) return;
const sanitized = text
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) =>
`<p>${line
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')}</p>`
)
.join('');
setForm((prev) => ({ ...prev, advice: `${prev.advice || ''}${sanitized}` }));
};
const handleSubmit = () => {
if (!form.advice) {
setError('Behandeladvies is verplicht.');
return;
}
if (finalize && !form.outcome) {
setError('Kies een vervolgoptie voordat je afrondt.');
return;
}
startTransition(async () => {
try {
await saveTreatmentAdvice({
patientId,
intakeId,
advice: form.advice || '',
department: form.department,
program: form.program,
notes: form.notes,
psychologist: form.psychologist,
finalize,
outcome: form.outcome,
outcomeNotes: form.outcomeNotes,
});
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Opslaan mislukt');
}
});
};
return (
<div className="space-y-4">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div className="rounded-lg border border-slate-200 p-4 space-y-3">
<div>
<label className="text-xs font-semibold text-slate-500 flex items-center gap-2">
<Calendar className="h-4 w-4" /> Datum advies
</label>
<div className="text-sm text-slate-900">{initialDate}</div>
</div>
<div>
<label className="text-xs font-semibold text-slate-500 flex items-center gap-2">
<UserCircle className="h-4 w-4" /> Behandelend psycholoog
</label>
<input
type="text"
value={form.psychologist}
onChange={(e) => setForm((prev) => ({ ...prev, psychologist: e.target.value }))}
className="mt-1 h-9 w-full rounded-md border border-slate-300 px-3 text-sm"
placeholder="Naam psycholoog"
/>
</div>
<div>
<label className="text-xs font-semibold text-slate-500 flex items-center gap-2">
<ClipboardList className="h-4 w-4" /> Afdeling
</label>
<select
value={form.department}
onChange={(e) => setForm((prev) => ({ ...prev, department: e.target.value }))}
className="mt-1 h-9 w-full rounded-md border border-slate-300 px-3 text-sm"
>
<option value="">Selecteer afdeling</option>
{departments.map((dept) => (
<option key={dept} value={dept}>
{dept}
</option>
))}
</select>
</div>
<div>
<label className="text-xs font-semibold text-slate-500 flex items-center gap-2">
<ClipboardList className="h-4 w-4" /> Zorgprogramma
</label>
<select
value={form.program}
onChange={(e) => setForm((prev) => ({ ...prev, program: e.target.value }))}
className="mt-1 h-9 w-full rounded-md border border-slate-300 px-3 text-sm"
>
<option value="">Selecteer zorgprogramma</option>
{programs.map((prog) => (
<option key={prog} value={prog}>
{prog}
</option>
))}
</select>
</div>
</div>
<div className="rounded-lg border border-slate-200 p-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<label className="text-xs font-semibold text-slate-500 flex items-center gap-2">
<CheckCircle2 className="h-4 w-4" /> Intake afronden
</label>
<p className="text-xs text-slate-500">
Kies vervolgoptie om de intake definitief af te sluiten.
</p>
</div>
<input
type="checkbox"
checked={finalize}
onChange={(e) => setFinalize(e.target.checked)}
className="h-4 w-4 rounded border-slate-300"
/>
</div>
<div className="space-y-2">
{outcomeOptions.map((option) => (
<label key={option.value} className="flex items-center gap-2 text-sm text-slate-700">
<input
type="radio"
name="outcome"
value={option.value}
disabled={!finalize}
checked={form.outcome === option.value}
onChange={(e) => setForm((prev) => ({ ...prev, outcome: e.target.value as AdviceData['outcome'] }))}
className="h-4 w-4"
/>
{option.label}
</label>
))}
</div>
<textarea
value={form.outcomeNotes}
disabled={!finalize}
onChange={(e) => setForm((prev) => ({ ...prev, outcomeNotes: e.target.value }))}
placeholder="Toelichting op vervolg"
className="w-full rounded-md border border-slate-300 px-3 py-2 text-sm disabled:bg-slate-50"
/>
</div>
<div className="rounded-lg border border-slate-200 p-4 bg-slate-50 space-y-3">
<div className="flex items-center gap-2 text-sm font-semibold text-slate-700">
<Share2 className="h-4 w-4" /> Doorzetten naar behandelplan
</div>
<p className="text-xs text-slate-500">
Gebruik dit advies als basis voor het behandelplan of koppel direct door.
</p>
<Link
href={`/epd/patients/${patientId}/behandelplan`}
className="inline-flex items-center justify-center rounded-md border border-slate-300 px-3 py-2 text-xs font-medium text-slate-700 hover:bg-white"
>
Open behandelplan
</Link>
</div>
</div>
<div className="space-y-3">
<SpeechRecorder onTranscript={appendTranscript} />
<RichTextEditor
value={form.advice}
onChange={(html) => setForm((prev) => ({ ...prev, advice: html }))}
placeholder={DEFAULT_PLACEHOLDER}
/>
<textarea
value={form.notes}
onChange={(e) => setForm((prev) => ({ ...prev, notes: e.target.value }))}
placeholder="Aanvullende notities"
className="w-full rounded-md border border-slate-300 px-3 py-2 text-sm"
/>
{error && <p className="text-sm text-red-600">{error}</p>}
<button
type="button"
onClick={handleSubmit}
disabled={isPending}
className="inline-flex items-center gap-2 rounded-md bg-teal-600 px-4 py-2 text-sm font-medium text-white hover:bg-teal-700 disabled:opacity-60"
>
{isPending && <Loader2 className="h-4 w-4 animate-spin" />}
Opslaan
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { getTreatmentAdvice } from '../actions';
import { TreatmentAdviceForm } from './components/treatment-advice-form';
export default async function IntakeTreatmentAdvicePage({
params,
}: {
params: Promise<{ id: string; intakeId: string }>;
}) {
const { id, intakeId } = await params;
const advice = await getTreatmentAdvice(intakeId);
const today = new Date().toLocaleDateString('nl-NL');
return (
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold text-slate-900">Behandeladvies</h2>
<p className="text-sm text-slate-600">
Documenteer het behandeladvies en koppel het aan een programma/afdeling.
</p>
</div>
<TreatmentAdviceForm
patientId={id}
intakeId={intakeId}
initialData={advice}
initialDate={today}
initialPsychologist={advice?.psychologist}
/>
</div>
);
}

View File

@@ -0,0 +1,67 @@
import { Intake } from '@/lib/types/intake';
import { format } from 'date-fns';
import { nl } from 'date-fns/locale';
import { Calendar, Clock, FileText } from 'lucide-react';
interface IntakeHeaderProps {
intake: Intake;
}
export function IntakeHeader({ intake }: IntakeHeaderProps) {
const statusLabels: Record<string, string> = {
bezig: 'Bezig',
afgerond: 'Afgerond',
};
const statusColors: Record<string, string> = {
bezig: 'bg-blue-50 text-blue-700 border-blue-200',
afgerond: 'bg-green-50 text-green-700 border-green-200',
};
const status = intake.status || 'bezig';
const statusClass = statusColors[status] || 'bg-slate-50 text-slate-700 border-slate-200';
return (
<div className="bg-white border-b border-slate-200 px-6 py-4">
<div className="flex items-start justify-between">
<div className="flex items-center gap-4">
<div className="p-3 bg-teal-50 rounded-lg text-teal-600">
<FileText className="h-6 w-6" />
</div>
<div>
<div className="flex items-center gap-3 mb-1">
<h1 className="text-xl font-bold text-slate-900">{intake.title}</h1>
<span className={`px-2.5 py-0.5 rounded-full text-xs font-medium border ${statusClass}`}>
{statusLabels[status] || status}
</span>
</div>
<div className="flex items-center gap-4 text-sm text-slate-500">
<span className="font-medium text-slate-700">{intake.department}</span>
<span className="text-slate-300">|</span>
<div className="flex items-center gap-1.5">
<Calendar className="h-4 w-4" />
<span>
Start: {format(new Date(intake.start_date), 'd MMM yyyy', { locale: nl })}
</span>
</div>
{intake.end_date && (
<div className="flex items-center gap-1.5">
<Clock className="h-4 w-4" />
<span>
Eind: {format(new Date(intake.end_date), 'd MMM yyyy', { locale: nl })}
</span>
</div>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2">
{/* Placeholder for actions like Edit, Close, etc. */}
<button className="px-3 py-1.5 text-sm font-medium text-slate-600 hover:text-slate-900 hover:bg-slate-50 rounded-md transition-colors">
Bewerken
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,54 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from '@/lib/utils';
interface IntakeTabsProps {
patientId: string;
intakeId: string;
}
export function IntakeTabs({ patientId, intakeId }: IntakeTabsProps) {
const pathname = usePathname();
const baseUrl = `/epd/patients/${patientId}/intakes/${intakeId}`;
const tabs = [
{ name: 'Algemeen', href: baseUrl, exact: true },
{ name: 'Contactmomenten', href: `${baseUrl}/contacts` },
{ name: 'Kindcheck', href: `${baseUrl}/kindcheck` },
{ name: 'Risicotaxatie', href: `${baseUrl}/risk` },
{ name: 'Anamnese', href: `${baseUrl}/anamnese` },
{ name: 'Onderzoeken', href: `${baseUrl}/examination` },
{ name: 'ROM', href: `${baseUrl}/rom` },
{ name: 'Diagnose', href: `${baseUrl}/diagnosis` },
{ name: 'Behandeladvies', href: `${baseUrl}/behandeladvies` },
];
return (
<div className="border-b border-slate-200 bg-white px-6">
<nav className="-mb-px flex space-x-6 overflow-x-auto">
{tabs.map((tab) => {
const isActive = tab.exact
? pathname === tab.href
: pathname.startsWith(tab.href);
return (
<Link
key={tab.name}
href={tab.href}
className={cn(
'whitespace-nowrap border-b-2 py-4 px-1 text-sm font-medium transition-colors',
isActive
? 'border-teal-500 text-teal-600'
: 'border-transparent text-slate-500 hover:border-slate-300 hover:text-slate-700'
)}
>
{tab.name}
</Link>
);
})}
</nav>
</div>
);
}

View File

@@ -0,0 +1,182 @@
'use client';
import { useState, useTransition } from 'react';
import { createContactMoment, deleteContactMoment, type Encounter } from '../../actions';
import { Loader2, Trash2 } from 'lucide-react';
import { format } from 'date-fns';
import { nl } from 'date-fns/locale';
const contactTypes = [
'Intakegesprek',
'Aanvullend onderzoek',
'Telefonisch contact',
'Huisbezoek',
'Overig',
];
interface ContactManagerProps {
patientId: string;
intakeId: string;
contacts: Encounter[];
}
export function ContactManager({ patientId, intakeId, contacts }: ContactManagerProps) {
const [form, setForm] = useState({
date: '',
start: '',
end: '',
type: contactTypes[0],
location: 'Op locatie',
notes: '',
});
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const [deletingId, setDeletingId] = useState<string | null>(null);
const handleSubmit = () => {
if (!form.date || !form.start) {
setError('Datum en starttijd zijn verplicht.');
return;
}
setError(null);
startTransition(async () => {
try {
await createContactMoment({
patientId,
intakeId,
date: form.date,
startTime: form.start,
endTime: form.end,
type: form.type,
location: form.location,
notes: form.notes,
});
setForm({ ...form, notes: '', end: '' });
} catch (err) {
setError(err instanceof Error ? err.message : 'Opslaan mislukt');
}
});
};
const handleDelete = (id: string) => {
setDeletingId(id);
startTransition(async () => {
try {
await deleteContactMoment(patientId, intakeId, id);
} catch (err) {
setError(err instanceof Error ? err.message : 'Verwijderen mislukt');
} finally {
setDeletingId(null);
}
});
};
return (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4">
{contacts.length === 0 && (
<p className="text-sm text-slate-500">
Nog geen contactmomenten geregistreerd.
</p>
)}
{contacts.map((contact) => (
<div
key={contact.id}
className="rounded-lg border border-slate-200 p-4 flex flex-col gap-2"
>
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-slate-900">{contact.type_display}</p>
<p className="text-xs text-slate-500">
{format(new Date(contact.period_start), 'd MMM yyyy HH:mm', { locale: nl })}
{contact.period_end && (
<>
{' '}-{' '}
{format(new Date(contact.period_end), 'HH:mm', { locale: nl })}
</>
)}
</p>
</div>
<button
type="button"
onClick={() => handleDelete(contact.id)}
disabled={deletingId === contact.id}
className="inline-flex items-center gap-1 rounded-md border border-red-200 px-3 py-1.5 text-xs text-red-600 hover:bg-red-50"
>
{deletingId === contact.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
Verwijder
</button>
</div>
{contact.notes && (
<p className="text-sm text-slate-700 whitespace-pre-line">{contact.notes}</p>
)}
</div>
))}
</div>
<div className="rounded-lg border border-slate-200 p-4 space-y-3">
<h3 className="text-sm font-semibold text-slate-900">Nieuw contactmoment</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<input
type="date"
value={form.date}
onChange={(e) => setForm((prev) => ({ ...prev, date: e.target.value }))}
className="h-10 rounded-md border border-slate-300 px-3 text-sm"
/>
<input
type="time"
value={form.start}
onChange={(e) => setForm((prev) => ({ ...prev, start: e.target.value }))}
className="h-10 rounded-md border border-slate-300 px-3 text-sm"
/>
<input
type="time"
value={form.end}
onChange={(e) => setForm((prev) => ({ ...prev, end: e.target.value }))}
className="h-10 rounded-md border border-slate-300 px-3 text-sm"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<select
value={form.type}
onChange={(e) => setForm((prev) => ({ ...prev, type: e.target.value }))}
className="h-10 rounded-md border border-slate-300 px-3 text-sm"
>
{contactTypes.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
<input
type="text"
value={form.location}
onChange={(e) => setForm((prev) => ({ ...prev, location: e.target.value }))}
placeholder="Locatie"
className="h-10 rounded-md border border-slate-300 px-3 text-sm"
/>
</div>
<textarea
value={form.notes}
onChange={(e) => setForm((prev) => ({ ...prev, notes: e.target.value }))}
placeholder="Notities"
className="w-full rounded-md border border-slate-300 px-3 py-2 text-sm"
/>
{error && <p className="text-sm text-red-600">{error}</p>}
<button
type="button"
onClick={handleSubmit}
disabled={isPending}
className="inline-flex items-center gap-2 rounded-md bg-teal-600 px-4 py-2 text-sm font-medium text-white hover:bg-teal-700 disabled:opacity-60"
>
{isPending && <Loader2 className="h-4 w-4 animate-spin" />}
Opslaan
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,23 @@
import { ContactManager } from './components/contact-manager';
import { getContactMoments } from '../actions';
export default async function IntakeContactsPage({
params,
}: {
params: Promise<{ id: string; intakeId: string }>;
}) {
const { id, intakeId } = await params;
const contacts = await getContactMoments(intakeId);
return (
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold text-slate-900">Contactmomenten</h2>
<p className="text-sm text-slate-600">
Registreer gesprekken, telefoontjes en andere contactmomenten.
</p>
</div>
<ContactManager patientId={id} intakeId={intakeId} contacts={contacts} />
</div>
);
}

View File

@@ -0,0 +1,151 @@
'use client';
import { useState, useTransition } from 'react';
import { createDiagnosis, deleteDiagnosis, type Condition } from '../../actions';
import { Loader2, Trash2 } from 'lucide-react';
import { format } from 'date-fns';
import { nl } from 'date-fns/locale';
const severities = ['licht', 'matig', 'ernstig'];
const statuses = ['active', 'resolved', 'entered-in-error'];
interface DiagnosisManagerProps {
patientId: string;
intakeId: string;
diagnoses: Condition[];
}
export function DiagnosisManager({ patientId, intakeId, diagnoses }: DiagnosisManagerProps) {
const [form, setForm] = useState({ code: '', description: '', severity: severities[0], status: statuses[0], notes: '' });
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const [deletingId, setDeletingId] = useState<string | null>(null);
const handleSubmit = () => {
if (!form.code || !form.description) {
setError('Code en omschrijving zijn verplicht.');
return;
}
startTransition(async () => {
try {
await createDiagnosis({
patientId,
intakeId,
code: form.code,
description: form.description,
severity: form.severity,
status: form.status,
notes: form.notes,
});
setForm({ ...form, notes: '' });
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Opslaan mislukt');
}
});
};
const handleDelete = (id: string) => {
setDeletingId(id);
startTransition(async () => {
try {
await deleteDiagnosis(patientId, intakeId, id);
} catch (err) {
setError(err instanceof Error ? err.message : 'Verwijderen mislukt');
} finally {
setDeletingId(null);
}
});
};
return (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-3">
{diagnoses.length === 0 && <p className="text-sm text-slate-500">Nog geen diagnoses.</p>}
{diagnoses.map((diag) => (
<div key={diag.id} className="rounded-lg border border-slate-200 p-4 space-y-1">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-slate-900">{diag.code_code} {diag.code_display}</p>
<p className="text-xs text-slate-500">
{diag.recorded_date
? format(new Date(diag.recorded_date), 'd MMM yyyy', { locale: nl })
: 'Onbekende datum'}
</p>
</div>
<button
type="button"
onClick={() => handleDelete(diag.id)}
disabled={deletingId === diag.id}
className="inline-flex items-center gap-1 rounded-md border border-red-200 px-3 py-1.5 text-xs text-red-600"
>
{deletingId === diag.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />} Verwijder
</button>
</div>
{diag.note && <p className="text-sm text-slate-700">{diag.note}</p>}
</div>
))}
</div>
<div className="rounded-lg border border-slate-200 p-4 space-y-3">
<h3 className="text-sm font-semibold text-slate-900">Nieuwe diagnose</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<input
type="text"
placeholder="DSM code"
value={form.code}
onChange={(e) => setForm((prev) => ({ ...prev, code: e.target.value }))}
className="h-10 rounded-md border border-slate-300 px-3 text-sm"
/>
<input
type="text"
placeholder="Omschrijving"
value={form.description}
onChange={(e) => setForm((prev) => ({ ...prev, description: e.target.value }))}
className="h-10 rounded-md border border-slate-300 px-3 text-sm"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<select
value={form.severity}
onChange={(e) => setForm((prev) => ({ ...prev, severity: e.target.value }))}
className="h-10 rounded-md border border-slate-300 px-3 text-sm"
>
{severities.map((sev) => (
<option key={sev} value={sev}>
{sev}
</option>
))}
</select>
<select
value={form.status}
onChange={(e) => setForm((prev) => ({ ...prev, status: e.target.value }))}
className="h-10 rounded-md border border-slate-300 px-3 text-sm"
>
{statuses.map((status) => (
<option key={status} value={status}>
{status}
</option>
))}
</select>
</div>
<textarea
value={form.notes}
onChange={(e) => setForm((prev) => ({ ...prev, notes: e.target.value }))}
placeholder="Notities"
className="w-full rounded-md border border-slate-300 px-3 py-2 text-sm"
/>
{error && <p className="text-sm text-red-600">{error}</p>}
<button
type="button"
onClick={handleSubmit}
disabled={isPending}
className="inline-flex items-center gap-2 rounded-md bg-teal-600 px-4 py-2 text-sm font-medium text-white"
>
{isPending && <Loader2 className="h-4 w-4 animate-spin" />}
Opslaan
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,23 @@
import { getDiagnoses } from '../actions';
import { DiagnosisManager } from './components/diagnosis-manager';
export default async function IntakeDiagnosisPage({
params,
}: {
params: Promise<{ id: string; intakeId: string }>;
}) {
const { id, intakeId } = await params;
const diagnoses = await getDiagnoses(intakeId);
return (
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold text-slate-900">Diagnoses</h2>
<p className="text-sm text-slate-600">
Registreer DSM-5 diagnoses gekoppeld aan deze intake.
</p>
</div>
<DiagnosisManager patientId={id} intakeId={intakeId} diagnoses={diagnoses} />
</div>
);
}

View File

@@ -0,0 +1,167 @@
'use client';
import { useState, useTransition } from 'react';
import { createExamination, deleteExamination, type Examination } from '../../actions';
import { Loader2, Trash2 } from 'lucide-react';
import { format } from 'date-fns';
import { nl } from 'date-fns/locale';
const examinationTypes = ['Bloedonderzoek', 'Neuropsychologisch onderzoek', 'Psychodiagnostiek', 'IQ-test', 'Persoonlijkheidsonderzoek', 'Overig'];
interface ExaminationManagerProps {
patientId: string;
intakeId: string;
examinations: Examination[];
isRom?: boolean;
}
export function ExaminationManager({ patientId, intakeId, examinations, isRom }: ExaminationManagerProps) {
const filtered = examinations.filter((exam) =>
isRom ? exam.examination_type === 'ROM' : exam.examination_type !== 'ROM'
);
const [form, setForm] = useState({
date: '',
type: examinationTypes[0],
performer: '',
findings: '',
reason: '',
notes: '',
});
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const [deletingId, setDeletingId] = useState<string | null>(null);
const handleSubmit = () => {
if (!form.date || !form.findings) {
setError('Datum en bevindingen zijn verplicht.');
return;
}
setError(null);
startTransition(async () => {
try {
await createExamination({
patientId,
intakeId,
date: form.date,
type: form.type,
findings: form.findings,
performer: form.performer,
reason: form.reason,
notes: form.notes,
isRom,
});
setForm({ ...form, findings: '', notes: '' });
} catch (err) {
setError(err instanceof Error ? err.message : 'Opslaan mislukt');
}
});
};
const handleDelete = (id: string) => {
setDeletingId(id);
startTransition(async () => {
try {
await deleteExamination(patientId, intakeId, id);
} catch (err) {
setError(err instanceof Error ? err.message : 'Verwijderen mislukt');
} finally {
setDeletingId(null);
}
});
};
return (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-3">
{filtered.length === 0 && (
<p className="text-sm text-slate-500">
{isRom ? 'Nog geen ROM-metingen' : 'Nog geen onderzoeken geregistreerd.'}
</p>
)}
{filtered.map((exam) => (
<div key={exam.id} className="rounded-lg border border-slate-200 p-4 space-y-1">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-slate-900">{exam.examination_type}</p>
<p className="text-xs text-slate-500">
{format(new Date(exam.examination_date), 'd MMM yyyy', { locale: nl })}
</p>
</div>
<button
type="button"
onClick={() => handleDelete(exam.id)}
disabled={deletingId === exam.id}
className="inline-flex items-center gap-1 rounded-md border border-red-200 px-3 py-1.5 text-xs text-red-600"
>
{deletingId === exam.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />} Verwijder
</button>
</div>
<p className="text-sm text-slate-700 whitespace-pre-line">{exam.findings}</p>
{exam.notes && <p className="text-xs text-slate-500">Notities: {exam.notes}</p>}
</div>
))}
</div>
<div className="rounded-lg border border-slate-200 p-4 space-y-3">
<h3 className="text-sm font-semibold text-slate-900">
{isRom ? 'Nieuwe ROM-meting' : 'Nieuw onderzoek'}
</h3>
<input
type="date"
value={form.date}
onChange={(e) => setForm((prev) => ({ ...prev, date: e.target.value }))}
className="h-10 rounded-md border border-slate-300 px-3 text-sm"
/>
{!isRom && (
<select
value={form.type}
onChange={(e) => setForm((prev) => ({ ...prev, type: e.target.value }))}
className="h-10 rounded-md border border-slate-300 px-3 text-sm"
>
{examinationTypes.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
)}
<input
type="text"
placeholder={isRom ? 'Instrument / score' : 'Uitgevoerd door'}
value={form.performer}
onChange={(e) => setForm((prev) => ({ ...prev, performer: e.target.value }))}
className="h-10 rounded-md border border-slate-300 px-3 text-sm"
/>
<textarea
value={form.findings}
onChange={(e) => setForm((prev) => ({ ...prev, findings: e.target.value }))}
placeholder={isRom ? 'Score en interpretatie' : 'Bevindingen'}
className="w-full rounded-md border border-slate-300 px-3 py-2 text-sm"
/>
<textarea
value={form.reason}
onChange={(e) => setForm((prev) => ({ ...prev, reason: e.target.value }))}
placeholder="Reden"
className="w-full rounded-md border border-slate-300 px-3 py-2 text-sm"
/>
<textarea
value={form.notes}
onChange={(e) => setForm((prev) => ({ ...prev, notes: e.target.value }))}
placeholder="Notities"
className="w-full rounded-md border border-slate-300 px-3 py-2 text-sm"
/>
{error && <p className="text-sm text-red-600">{error}</p>}
<button
type="button"
onClick={handleSubmit}
disabled={isPending}
className="inline-flex items-center gap-2 rounded-md bg-teal-600 px-4 py-2 text-sm font-medium text-white"
>
{isPending && <Loader2 className="h-4 w-4 animate-spin" />}
Opslaan
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,23 @@
import { getExaminations } from '../actions';
import { ExaminationManager } from './components/examination-manager';
export default async function IntakeExaminationPage({
params,
}: {
params: Promise<{ id: string; intakeId: string }>;
}) {
const { id, intakeId } = await params;
const examinations = await getExaminations(intakeId);
return (
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold text-slate-900">Onderzoeken</h2>
<p className="text-sm text-slate-600">
Psychodiagnostiek, medische onderzoeken en rapportage.
</p>
</div>
<ExaminationManager patientId={id} intakeId={intakeId} examinations={examinations} />
</div>
);
}

View File

@@ -0,0 +1,127 @@
'use client';
import { useState, useTransition } from 'react';
import { saveKindcheck, type KindcheckData } from '../../actions';
import { Loader2 } from 'lucide-react';
interface KindcheckFormProps {
patientId: string;
intakeId: string;
initialData: KindcheckData;
}
export function KindcheckForm({ patientId, intakeId, initialData }: KindcheckFormProps) {
const [form, setForm] = useState<KindcheckData>({ ...initialData });
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const handleSubmit = () => {
startTransition(async () => {
try {
await saveKindcheck(patientId, intakeId, form);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Opslaan mislukt');
}
});
};
return (
<div className="space-y-4 rounded-lg border border-slate-200 p-4">
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-slate-700">Thuiswonende kinderen?</label>
<select
value={form.hasChildren ? 'yes' : 'no'}
onChange={(e) =>
setForm((prev) => ({ ...prev, hasChildren: e.target.value === 'yes' }))
}
className="h-9 rounded-md border border-slate-300 px-3 text-sm"
>
<option value="no">Nee</option>
<option value="yes">Ja</option>
</select>
</div>
{form.hasChildren && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<input
type="number"
placeholder="Aantal kinderen"
value={form.childCount ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, childCount: Number(e.target.value) || 0 }))}
className="h-10 rounded-md border border-slate-300 px-3 text-sm"
/>
<input
type="text"
placeholder="Leeftijden"
value={form.ages ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, ages: e.target.value }))}
className="h-10 rounded-md border border-slate-300 px-3 text-sm"
/>
</div>
)}
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-slate-700">Zorgen over veiligheid?</label>
<select
value={form.concerns ? 'yes' : 'no'}
onChange={(e) =>
setForm((prev) => ({ ...prev, concerns: e.target.value === 'yes' }))
}
className="h-9 rounded-md border border-slate-300 px-3 text-sm"
>
<option value="no">Nee</option>
<option value="yes">Ja</option>
</select>
</div>
{form.concerns && (
<textarea
value={form.concernsNotes ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, concernsNotes: e.target.value }))}
placeholder="Toelichting"
className="w-full rounded-md border border-slate-300 px-3 py-2 text-sm"
/>
)}
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-slate-700">Actie ondernomen?</label>
<select
value={form.actionTaken ? 'yes' : 'no'}
onChange={(e) =>
setForm((prev) => ({ ...prev, actionTaken: e.target.value === 'yes' }))
}
className="h-9 rounded-md border border-slate-300 px-3 text-sm"
>
<option value="no">Nee</option>
<option value="yes">Ja</option>
</select>
</div>
{form.actionTaken && (
<textarea
value={form.actionNotes ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, actionNotes: e.target.value }))}
placeholder="Beschrijving van actie"
className="w-full rounded-md border border-slate-300 px-3 py-2 text-sm"
/>
)}
<textarea
value={form.notes ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, notes: e.target.value }))}
placeholder="Notities"
className="w-full rounded-md border border-slate-300 px-3 py-2 text-sm"
/>
{error && <p className="text-sm text-red-600">{error}</p>}
<button
type="button"
onClick={handleSubmit}
disabled={isPending}
className="inline-flex items-center gap-2 rounded-md bg-teal-600 px-4 py-2 text-sm font-medium text-white hover:bg-teal-700 disabled:opacity-60"
>
{isPending && <Loader2 className="h-4 w-4 animate-spin" />}
Opslaan
</button>
</div>
);
}

View File

@@ -0,0 +1,23 @@
import { getKindcheck } from '../actions';
import { KindcheckForm } from './components/kindcheck-form';
export default async function IntakeKindcheckPage({
params,
}: {
params: Promise<{ id: string; intakeId: string }>;
}) {
const { id, intakeId } = await params;
const data = await getKindcheck(intakeId);
return (
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold text-slate-900">Kindcheck</h2>
<p className="text-sm text-slate-600">
Registreer aanwezigheid van kinderen, zorgen en ondernomen acties.
</p>
</div>
<KindcheckForm patientId={id} intakeId={intakeId} initialData={data} />
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { getIntakeById } from '../actions';
import { IntakeHeader } from './components/intake-header';
import { IntakeTabs } from './components/intake-tabs';
import { notFound } from 'next/navigation';
import { ReactNode } from 'react';
interface IntakeLayoutProps {
children: ReactNode;
params: Promise<{ id: string; intakeId: string }>;
}
export default async function IntakeLayout({ children, params }: IntakeLayoutProps) {
const { id, intakeId } = await params;
const intake = await getIntakeById(intakeId);
if (!intake) {
notFound();
}
return (
<div className="flex flex-col h-full bg-slate-50">
<IntakeHeader intake={intake} />
<IntakeTabs patientId={id} intakeId={intakeId} />
<div className="flex-1 p-6 overflow-auto">
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,53 @@
import { getIntakeById } from '../actions';
import { notFound } from 'next/navigation';
interface IntakePageProps {
params: Promise<{ intakeId: string }>;
}
export default async function IntakePage({ params }: IntakePageProps) {
const { intakeId } = await params;
const intake = await getIntakeById(intakeId);
if (!intake) {
notFound();
}
return (
<div className="max-w-3xl">
<div className="bg-white rounded-lg border border-slate-200 p-6 shadow-sm">
<h2 className="text-lg font-semibold text-slate-900 mb-4">Algemene Informatie</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-slate-500 mb-1">Titel</label>
<p className="text-slate-900">{intake.title}</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-500 mb-1">Afdeling</label>
<p className="text-slate-900">{intake.department}</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-500 mb-1">Status</label>
<p className="text-slate-900">{intake.status}</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-500 mb-1">Startdatum</label>
<p className="text-slate-900">{intake.start_date}</p>
</div>
</div>
<div className="mt-6 pt-6 border-t border-slate-100">
<label className="block text-sm font-medium text-slate-500 mb-2">Notities</label>
<div className="bg-slate-50 rounded-md p-4 text-slate-600 text-sm min-h-[100px]">
{intake.notes || 'Geen notities beschikbaar.'}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,175 @@
'use client';
import { useState, useTransition } from 'react';
import {
createRiskAssessment,
deleteRiskAssessment,
type RiskAssessment,
} from '../../actions';
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'];
interface RiskManagerProps {
patientId: string;
intakeId: string;
risks: RiskAssessment[];
}
export function RiskManager({ patientId, intakeId, risks }: RiskManagerProps) {
const [form, setForm] = useState({
date: '',
type: riskTypes[0],
level: riskLevels[0],
rationale: '',
measures: '',
evaluationDate: '',
notes: '',
});
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const [deletingId, setDeletingId] = useState<string | null>(null);
const handleSubmit = () => {
if (!form.date || !form.rationale) {
setError('Datum en onderbouwing zijn verplicht.');
return;
}
setError(null);
startTransition(async () => {
try {
await createRiskAssessment({
patientId,
intakeId,
date: form.date,
type: form.type,
level: form.level,
rationale: form.rationale,
measures: form.measures,
evaluationDate: form.evaluationDate || undefined,
notes: form.notes,
});
setForm({ ...form, rationale: '', measures: '', notes: '' });
} catch (err) {
setError(err instanceof Error ? err.message : 'Opslaan mislukt');
}
});
};
const handleDelete = (id: string) => {
setDeletingId(id);
startTransition(async () => {
try {
await deleteRiskAssessment(patientId, intakeId, id);
} catch (err) {
setError(err instanceof Error ? err.message : 'Verwijderen mislukt');
} finally {
setDeletingId(null);
}
});
};
return (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-3">
{risks.length === 0 && (
<p className="text-sm text-slate-500">Nog geen risicotaxaties vastgelegd.</p>
)}
{risks.map((risk) => (
<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="text-xs text-slate-500">
{format(new Date(risk.assessment_date), 'd MMM yyyy', { locale: nl })} {risk.risk_level}
</p>
</div>
<button
type="button"
onClick={() => handleDelete(risk.id)}
disabled={deletingId === risk.id}
className="inline-flex items-center gap-1 rounded-md border border-red-200 px-3 py-1.5 text-xs text-red-600"
>
{deletingId === risk.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />} Verwijder
</button>
</div>
<p className="text-sm text-slate-700 whitespace-pre-line">{risk.rationale}</p>
{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">
<h3 className="text-sm font-semibold text-slate-900">Nieuwe risicotaxatie</h3>
<input
type="date"
value={form.date}
onChange={(e) => setForm((prev) => ({ ...prev, date: e.target.value }))}
className="h-10 rounded-md border border-slate-300 px-3 text-sm"
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<select
value={form.type}
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}
</option>
))}
</select>
<select
value={form.level}
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}
</option>
))}
</select>
</div>
<textarea
value={form.rationale}
onChange={(e) => setForm((prev) => ({ ...prev, rationale: e.target.value }))}
placeholder="Onderbouwing"
className="w-full rounded-md border border-slate-300 px-3 py-2 text-sm"
/>
<textarea
value={form.measures}
onChange={(e) => setForm((prev) => ({ ...prev, measures: e.target.value }))}
placeholder="Maatregelen"
className="w-full rounded-md border border-slate-300 px-3 py-2 text-sm"
/>
<input
type="date"
value={form.evaluationDate}
onChange={(e) => setForm((prev) => ({ ...prev, evaluationDate: e.target.value }))}
className="h-10 rounded-md border border-slate-300 px-3 text-sm"
/>
<textarea
value={form.notes}
onChange={(e) => setForm((prev) => ({ ...prev, notes: e.target.value }))}
placeholder="Notities"
className="w-full rounded-md border border-slate-300 px-3 py-2 text-sm"
/>
{error && <p className="text-sm text-red-600">{error}</p>}
<button
type="button"
onClick={handleSubmit}
disabled={isPending}
className="inline-flex items-center gap-2 rounded-md bg-teal-600 px-4 py-2 text-sm font-medium text-white"
>
{isPending && <Loader2 className="h-4 w-4 animate-spin" />}
Opslaan
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,23 @@
import { getRiskAssessments } from '../actions';
import { RiskManager } from './components/risk-manager';
export default async function IntakeRiskPage({
params,
}: {
params: Promise<{ id: string; intakeId: string }>;
}) {
const { id, intakeId } = await params;
const risks = await getRiskAssessments(intakeId);
return (
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold text-slate-900">Risicotaxaties</h2>
<p className="text-sm text-slate-600">
Vastleggen van risico-inschattingen en opvolgacties.
</p>
</div>
<RiskManager patientId={id} intakeId={intakeId} risks={risks} />
</div>
);
}

View File

@@ -0,0 +1,28 @@
import { getExaminations } from '../actions';
import { ExaminationManager } from '../examination/components/examination-manager';
export default async function IntakeRomPage({
params,
}: {
params: Promise<{ id: string; intakeId: string }>;
}) {
const { id, intakeId } = await params;
const examinations = await getExaminations(intakeId);
return (
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold text-slate-900">ROM-metingen</h2>
<p className="text-sm text-slate-600">
Registeren van ROM-scores gekoppeld aan deze intake.
</p>
</div>
<ExaminationManager
patientId={id}
intakeId={intakeId}
examinations={examinations}
isRom
/>
</div>
);
}

View File

@@ -0,0 +1,335 @@
'use server';
/**
* Intake Server Actions (API-based)
*
* Server-side actions that interact with Intake API endpoints
* Refactored from direct Supabase queries to use Custom API
*/
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { headers, cookies } from 'next/headers';
import type {
Intake,
CreateIntakeInput,
UpdateIntakeInput,
IntakeListResponse,
} from '@/lib/types/intake';
/**
* Get the base URL for API calls in server actions
* Uses headers() to get the host from the request
*/
function getBaseUrl(): string {
// Try environment variable first
if (process.env.NEXT_PUBLIC_APP_URL) {
return process.env.NEXT_PUBLIC_APP_URL;
}
// Try to get from headers (works in server components/actions)
try {
const headersList = headers();
const host = headersList.get('host');
const protocol = headersList.get('x-forwarded-proto') || 'http';
if (host) {
return `${protocol}://${host}`;
}
} catch {
// Headers not available, fallback to localhost
}
// Fallback to localhost
return 'http://localhost:3000';
}
/**
* Get cookies as a string for fetch headers
*/
async function getCookieHeader(): Promise<string> {
try {
const cookieStore = await cookies();
return cookieStore
.getAll()
.map((cookie) => `${cookie.name}=${cookie.value}`)
.join('; ');
} catch {
return '';
}
}
/**
* Get all intakes for a specific patient
* @param patientId - UUID of the patient
* @returns Array of intakes for the patient
*/
export async function getIntakesByPatientId(patientId: string): Promise<Intake[]> {
try {
const baseUrl = getBaseUrl();
const url = new URL('/api/intakes', baseUrl);
url.searchParams.set('patientId', patientId);
const cookieHeader = await getCookieHeader();
const response = await fetch(url.toString(), {
cache: 'no-store',
headers: {
...(cookieHeader && { Cookie: cookieHeader }),
},
});
if (!response.ok) {
const errorText = await response.text();
let errorData: any = {};
try {
errorData = JSON.parse(errorText);
} catch {
// If not JSON, check if it's HTML (redirect)
if (errorText.trim().startsWith('<!DOCTYPE') || errorText.trim().startsWith('<html')) {
throw new Error('Niet geautoriseerd. Log opnieuw in.');
}
}
console.error('Error fetching intakes:', response.statusText, errorData);
throw new Error(`Failed to fetch intakes: ${errorData.error || response.statusText}`);
}
const data: IntakeListResponse = await response.json();
return data.intakes;
} catch (error) {
console.error('Error in getIntakesByPatientId:', error);
throw error instanceof Error ? error : new Error('Failed to fetch intakes');
}
}
/**
* Get a specific intake by ID
* @param intakeId - UUID of the intake
* @returns Intake object or null if not found
*/
export async function getIntakeById(intakeId: string): Promise<Intake | null> {
try {
const baseUrl = getBaseUrl();
const url = `${baseUrl}/api/intakes/${intakeId}`;
const cookieHeader = await getCookieHeader();
const response = await fetch(url, {
cache: 'no-store',
headers: {
...(cookieHeader && { Cookie: cookieHeader }),
},
});
if (response.status === 404) {
return null;
}
if (!response.ok) {
const errorText = await response.text();
let errorData: any = {};
try {
errorData = JSON.parse(errorText);
} catch {
// If not JSON, check if it's HTML (redirect)
if (errorText.trim().startsWith('<!DOCTYPE') || errorText.trim().startsWith('<html')) {
throw new Error('Niet geautoriseerd. Log opnieuw in.');
}
}
console.error('Error fetching intake:', response.statusText, errorData);
throw new Error(`Failed to fetch intake: ${errorData.error || response.statusText}`);
}
const data: Intake = await response.json();
return data;
} catch (error) {
console.error('Error in getIntakeById:', error);
// Return null instead of throwing for not found cases
if (error instanceof Error && error.message.includes('404')) {
return null;
}
throw error instanceof Error ? error : new Error('Failed to fetch intake');
}
}
/**
* Create a new intake
* @param input - Intake creation data
* @returns Created intake object
*/
export async function createIntake(input: CreateIntakeInput): Promise<Intake> {
try {
const baseUrl = getBaseUrl();
const url = `${baseUrl}/api/intakes`;
const cookieHeader = await getCookieHeader();
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(cookieHeader && { Cookie: cookieHeader }),
},
body: JSON.stringify(input),
});
if (!response.ok) {
const errorText = await response.text();
let errorData: any = {};
try {
errorData = JSON.parse(errorText);
} catch {
// If not JSON, check if it's HTML (redirect)
if (errorText.trim().startsWith('<!DOCTYPE') || errorText.trim().startsWith('<html')) {
throw new Error('Niet geautoriseerd. Log opnieuw in.');
}
}
console.error('Error creating intake:', response.statusText, errorData);
// Handle validation errors
if (response.status === 400 && errorData.details) {
const validationErrors = errorData.details
.map((d: { field: string; message: string }) => `${d.field}: ${d.message}`)
.join(', ');
throw new Error(`Validatiefout: ${validationErrors}`);
}
throw new Error(`Failed to create intake: ${errorData.error || response.statusText}`);
}
const data: Intake = await response.json();
// Revalidate paths
revalidatePath(`/epd/patients/${input.patient_id}/intakes`);
revalidatePath(`/epd/patients/${input.patient_id}`);
// Redirect to intakes list
redirect(`/epd/patients/${input.patient_id}/intakes`);
// This will never be reached due to redirect, but TypeScript needs it
return data;
} catch (error) {
console.error('Error in createIntake:', error);
throw error instanceof Error ? error : new Error('Failed to create intake');
}
}
/**
* Update an existing intake
* @param intakeId - UUID of the intake to update
* @param input - Partial intake data to update
* @returns Updated intake object
*/
export async function updateIntake(
intakeId: string,
input: UpdateIntakeInput
): Promise<Intake> {
try {
const baseUrl = getBaseUrl();
const url = `${baseUrl}/api/intakes/${intakeId}`;
const cookieHeader = await getCookieHeader();
const response = await fetch(url, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...(cookieHeader && { Cookie: cookieHeader }),
},
body: JSON.stringify(input),
});
if (!response.ok) {
const errorText = await response.text();
let errorData: any = {};
try {
errorData = JSON.parse(errorText);
} catch {
// If not JSON, check if it's HTML (redirect)
if (errorText.trim().startsWith('<!DOCTYPE') || errorText.trim().startsWith('<html')) {
throw new Error('Niet geautoriseerd. Log opnieuw in.');
}
}
console.error('Error updating intake:', response.statusText, errorData);
if (response.status === 404) {
throw new Error('Intake niet gevonden');
}
if (response.status === 400 && errorData.details) {
const validationErrors = errorData.details
.map((d: { field: string; message: string }) => `${d.field}: ${d.message}`)
.join(', ');
throw new Error(`Validatiefout: ${validationErrors}`);
}
throw new Error(`Failed to update intake: ${errorData.error || response.statusText}`);
}
const data: Intake = await response.json();
// Revalidate paths (we need to get patient_id from the intake)
revalidatePath(`/epd/patients/${data.patient_id}/intakes`);
revalidatePath(`/epd/patients/${data.patient_id}/intakes/${intakeId}`);
revalidatePath(`/epd/patients/${data.patient_id}`);
return data;
} catch (error) {
console.error('Error in updateIntake:', error);
throw error instanceof Error ? error : new Error('Failed to update intake');
}
}
/**
* Delete an intake
* @param intakeId - UUID of the intake to delete
* @param patientId - UUID of the patient (for revalidation)
*/
export async function deleteIntake(intakeId: string, patientId: string): Promise<void> {
try {
const baseUrl = getBaseUrl();
const url = `${baseUrl}/api/intakes/${intakeId}`;
const cookieHeader = await getCookieHeader();
const response = await fetch(url, {
method: 'DELETE',
headers: {
...(cookieHeader && { Cookie: cookieHeader }),
},
});
if (!response.ok && response.status !== 204) {
const errorText = await response.text();
let errorData: any = {};
try {
errorData = JSON.parse(errorText);
} catch {
// If not JSON, check if it's HTML (redirect)
if (errorText.trim().startsWith('<!DOCTYPE') || errorText.trim().startsWith('<html')) {
throw new Error('Niet geautoriseerd. Log opnieuw in.');
}
}
console.error('Error deleting intake:', response.statusText, errorData);
if (response.status === 404) {
throw new Error('Intake niet gevonden');
}
throw new Error(`Failed to delete intake: ${errorData.error || response.statusText}`);
}
// Revalidate paths
revalidatePath(`/epd/patients/${patientId}/intakes`);
revalidatePath(`/epd/patients/${patientId}`);
} catch (error) {
console.error('Error in deleteIntake:', error);
throw error instanceof Error ? error : new Error('Failed to delete intake');
}
}

View File

@@ -0,0 +1,73 @@
import { Calendar, ChevronRight, FileText } from 'lucide-react';
import { Intake } from '@/lib/types/intake';
import Link from 'next/link';
import { format } from 'date-fns';
import { nl } from 'date-fns/locale';
interface IntakeCardProps {
intake: Intake;
patientId: string;
}
export function IntakeCard({ intake, patientId }: IntakeCardProps) {
const statusLabels: Record<string, string> = {
bezig: 'Bezig',
afgerond: 'Afgerond',
};
const statusColors: Record<string, string> = {
bezig: 'bg-blue-50 text-blue-700 border-blue-200',
afgerond: 'bg-green-50 text-green-700 border-green-200',
};
const status = intake.status || 'bezig';
const statusClass = statusColors[status] || 'bg-slate-50 text-slate-700 border-slate-200';
return (
<Link
href={`/epd/patients/${patientId}/intakes/${intake.id}`}
className="block group"
>
<div className="bg-white border border-slate-200 rounded-lg p-4 hover:border-teal-500 hover:shadow-sm transition-all">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3">
<div className="p-2 bg-teal-50 rounded-md text-teal-600 group-hover:bg-teal-100 transition-colors">
<FileText className="h-5 w-5" />
</div>
<div>
<h3 className="font-medium text-slate-900 group-hover:text-teal-700 transition-colors">
{intake.title}
</h3>
<p className="text-sm text-slate-500">{intake.department}</p>
</div>
</div>
<span
className={`px-2.5 py-0.5 rounded-full text-xs font-medium border ${statusClass}`}
>
{statusLabels[status] || status}
</span>
</div>
<div className="flex items-center gap-4 text-sm text-slate-500 mt-4 pt-4 border-t border-slate-100">
<div className="flex items-center gap-1.5">
<Calendar className="h-4 w-4" />
<span>
{format(new Date(intake.start_date), 'd MMM yyyy', { locale: nl })}
</span>
</div>
{intake.end_date && (
<>
<span>&rarr;</span>
<span>
{format(new Date(intake.end_date), 'd MMM yyyy', { locale: nl })}
</span>
</>
)}
<div className="ml-auto">
<ChevronRight className="h-4 w-4 text-slate-300 group-hover:text-teal-500 transition-colors" />
</div>
</div>
</div>
</Link>
);
}

View File

@@ -0,0 +1,51 @@
'use client';
import { Intake } from '@/lib/types/intake';
import { IntakeCard } from './intake-card';
import { FileText } from 'lucide-react';
interface IntakeListProps {
intakes: Intake[];
patientId: string;
isLoading?: boolean;
}
export function IntakeList({ intakes, patientId, isLoading }: IntakeListProps) {
if (isLoading) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{[1, 2].map((i) => (
<div
key={i}
className="h-32 bg-slate-50 rounded-lg border border-slate-200 animate-pulse"
/>
))}
</div>
);
}
if (intakes.length === 0) {
return (
<div className="text-center py-12 bg-slate-50 rounded-lg border border-dashed border-slate-300">
<div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-slate-100 mb-4">
<FileText className="h-6 w-6 text-slate-400" />
</div>
<h3 className="text-sm font-medium text-slate-900 mb-1">
Geen intakes gevonden
</h3>
<p className="text-sm text-slate-500">
Start een nieuwe intake om te beginnen.
</p>
</div>
);
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{intakes.map((intake) => (
<IntakeCard key={intake.id} intake={intake} patientId={patientId} />
))}
</div>
);
}

View File

@@ -0,0 +1,138 @@
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { createIntake } from '../actions';
import { useState, useTransition } from 'react';
import { CalendarIcon, Loader2 } from 'lucide-react';
import { format } from 'date-fns';
import { nl } from 'date-fns/locale';
import { cn } from '@/lib/utils';
const formSchema = z.object({
title: z.string().min(1, 'Titel is verplicht'),
department: z.enum(['Volwassenen', 'Jeugd', 'Ouderen']),
start_date: z.string().min(1, 'Startdatum is verplicht'),
});
type FormData = z.infer<typeof formSchema>;
interface NewIntakeFormProps {
patientId: string;
}
export function NewIntakeForm({ patientId }: NewIntakeFormProps) {
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
title: '',
department: 'Volwassenen',
start_date: format(new Date(), 'yyyy-MM-dd'),
},
});
const onSubmit = (data: FormData) => {
setError(null);
startTransition(async () => {
try {
await createIntake({
...data,
patient_id: patientId,
});
} catch (e) {
setError('Er is een fout opgetreden bij het aanmaken van de intake.');
console.error(e);
}
});
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6 max-w-md">
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded-md">
{error}
</div>
)}
<div className="space-y-2">
<label htmlFor="title" className="text-sm font-medium text-slate-900">
Titel Intake
</label>
<input
id="title"
type="text"
{...register('title')}
placeholder="Bijv. Intake Depressie"
className={cn(
"flex h-10 w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent disabled:cursor-not-allowed disabled:opacity-50",
errors.title && "border-red-500 focus:ring-red-500"
)}
disabled={isPending}
/>
{errors.title && (
<p className="text-xs text-red-500">{errors.title.message}</p>
)}
</div>
<div className="space-y-2">
<label htmlFor="department" className="text-sm font-medium text-slate-900">
Afdeling
</label>
<select
id="department"
{...register('department')}
className="flex h-10 w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent disabled:cursor-not-allowed disabled:opacity-50"
disabled={isPending}
>
<option value="Volwassenen">Volwassenen</option>
<option value="Jeugd">Jeugd</option>
<option value="Ouderen">Ouderen</option>
</select>
{errors.department && (
<p className="text-xs text-red-500">{errors.department.message}</p>
)}
</div>
<div className="space-y-2">
<label htmlFor="start_date" className="text-sm font-medium text-slate-900">
Startdatum
</label>
<div className="relative">
<input
id="start_date"
type="date"
{...register('start_date')}
className={cn(
"flex h-10 w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent disabled:cursor-not-allowed disabled:opacity-50",
errors.start_date && "border-red-500 focus:ring-red-500"
)}
disabled={isPending}
/>
</div>
{errors.start_date && (
<p className="text-xs text-red-500">{errors.start_date.message}</p>
)}
</div>
<div className="pt-4">
<button
type="submit"
disabled={isPending}
className="w-full inline-flex items-center justify-center gap-2 px-4 py-2 bg-teal-600 text-white font-medium rounded-lg hover:bg-teal-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isPending && <Loader2 className="h-4 w-4 animate-spin" />}
{isPending ? 'Aanmaken...' : 'Start Intake'}
</button>
</div>
</form>
);
}

View File

@@ -0,0 +1,34 @@
import { NewIntakeForm } from '../components/new-intake-form';
import Link from 'next/link';
import { ChevronLeft } from 'lucide-react';
interface NewIntakePageProps {
params: Promise<{ id: string }>;
}
export default async function NewIntakePage({ params }: NewIntakePageProps) {
const { id } = await params;
return (
<div className="max-w-2xl mx-auto py-8">
<div className="mb-8">
<Link
href={`/epd/patients/${id}/intakes`}
className="inline-flex items-center gap-1 text-sm text-slate-500 hover:text-slate-900 mb-4 transition-colors"
>
<ChevronLeft className="h-4 w-4" />
Terug naar overzicht
</Link>
<h1 className="text-2xl font-bold text-slate-900">Nieuwe Intake Starten</h1>
<p className="text-slate-600 mt-2">
Vul de basisgegevens in om een nieuwe intake te starten.
</p>
</div>
<div className="bg-white rounded-lg border border-slate-200 p-6 shadow-sm">
<NewIntakeForm patientId={id} />
</div>
</div>
);
}

View File

@@ -0,0 +1,39 @@
import { getIntakesByPatientId } from './actions';
import { IntakeList } from './components/intake-list';
import { Plus } from 'lucide-react';
import Link from 'next/link';
interface IntakesPageProps {
params: Promise<{ id: string }>;
}
export default async function IntakesPage({ params }: IntakesPageProps) {
const { id } = await params;
const intakes = await getIntakesByPatientId(id);
return (
<div className="p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-lg font-semibold text-slate-900">
Intakes
</h2>
<p className="text-sm text-slate-600 mt-1">
Overzicht van alle intakes
</p>
</div>
<Link
href={`/epd/patients/${id}/intakes/new`}
className="inline-flex items-center gap-2 px-4 py-2 bg-teal-600 text-white font-medium rounded-lg hover:bg-teal-700 transition-colors"
>
<Plus className="h-4 w-4" />
<span>Nieuwe Intake</span>
</Link>
</div>
<IntakeList intakes={intakes} patientId={id} />
</div>
);
}

View File

@@ -1,7 +1,3 @@
import { getPatient } from '../actions';
import { ClientHeader } from './components/client-header';
import { ClientSidebar } from './components/client-sidebar';
export default async function PatientDetailLayout({
children,
params,
@@ -10,23 +6,6 @@ export default async function PatientDetailLayout({
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const patient = await getPatient(id);
return (
<div className="h-screen flex flex-col">
{/* Client Header */}
<ClientHeader patient={patient} />
{/* Main Content Area with Sidebar */}
<div className="flex-1 flex overflow-hidden">
{/* Sidebar Navigation */}
<ClientSidebar patientId={id} />
{/* Page Content */}
<main className="flex-1 overflow-y-auto bg-slate-50">
{children}
</main>
</div>
</div>
);
return <>{children}</>;
}

View File

@@ -10,7 +10,11 @@ import {
FileText,
ArrowRight,
AlertCircle,
Calendar,
} from 'lucide-react';
import { getIntakesByPatientId } from './intakes/actions';
import { format } from 'date-fns';
import { nl } from 'date-fns/locale';
export default async function PatientDashboardPage({
params,
@@ -18,6 +22,16 @@ export default async function PatientDashboardPage({
params: Promise<{ id: string }>;
}) {
const { id } = await params;
// Fetch recent intakes (optional)
let recentIntakes = [];
try {
const intakes = await getIntakesByPatientId(id);
recentIntakes = intakes.slice(0, 3); // Get up to 3 most recent
} catch (error) {
// Silently fail - intakes are optional for dashboard
console.error('Failed to fetch intakes for dashboard:', error);
}
return (
<div className="p-6">
@@ -71,7 +85,7 @@ export default async function PatientDashboardPage({
{/* Intake Card */}
<Link
href={`/epd/patients/${id}/intake`}
href={`/epd/patients/${id}/intakes`}
className="bg-white rounded-lg border border-slate-200 p-6 hover:border-teal-300 hover:shadow-md transition-all group"
>
<div className="flex items-start justify-between mb-4">
@@ -89,17 +103,82 @@ export default async function PatientDashboardPage({
</Link>
</div>
{/* Recent Intakes Section */}
{recentIntakes.length > 0 && (
<div className="bg-white rounded-lg border border-slate-200 p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-slate-900">Recente Intakes</h3>
<Link
href={`/epd/patients/${id}/intakes`}
className="text-sm text-teal-600 hover:text-teal-700 font-medium"
>
Bekijk alle
</Link>
</div>
<div className="space-y-3">
{recentIntakes.map((intake) => (
<Link
key={intake.id}
href={`/epd/patients/${id}/intakes/${intake.id}`}
className="block p-3 rounded-lg border border-slate-200 hover:border-teal-300 hover:bg-teal-50 transition-colors group"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-teal-50 rounded-md flex items-center justify-center group-hover:bg-teal-100 transition-colors">
<FileText className="h-4 w-4 text-teal-600" />
</div>
<div>
<p className="font-medium text-slate-900 group-hover:text-teal-700">
{intake.title}
</p>
<div className="flex items-center gap-2 text-xs text-slate-500 mt-0.5">
<span>{intake.department}</span>
<span></span>
<div className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
<span>
{format(new Date(intake.start_date), 'd MMM yyyy', { locale: nl })}
</span>
</div>
</div>
</div>
</div>
<span
className={`px-2 py-0.5 rounded-full text-xs font-medium ${
intake.status === 'Open'
? 'bg-blue-50 text-blue-700'
: 'bg-green-50 text-green-700'
}`}
>
{intake.status}
</span>
</div>
</Link>
))}
</div>
</div>
)}
{/* Next Steps Section */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6">
<div className="flex items-start gap-3">
<AlertCircle className="h-5 w-5 text-blue-600 flex-shrink-0 mt-0.5" />
<div>
<div className="flex-1">
<h3 className="font-medium text-blue-900 mb-2">Volgende stappen</h3>
<ul className="text-sm text-blue-800 space-y-1">
<li> Controleer en vul basisgegevens aan indien nodig</li>
<li> Start screening door activiteiten te loggen</li>
<li> Upload relevante documenten (verwijsbrief, etc.)</li>
<li> Neem screeningsbesluit om door te gaan naar intake</li>
<li>
Start een nieuwe intake via de{' '}
<Link
href={`/epd/patients/${id}/intakes`}
className="underline font-medium hover:text-blue-900"
>
Intake module
</Link>
</li>
</ul>
</div>
</div>

View File

@@ -0,0 +1,171 @@
'use server';
import { revalidatePath } from 'next/cache';
import { headers, cookies } from 'next/headers';
import type {
ScreeningSummary,
ScreeningWithRelations,
} from '@/lib/types/screening';
function getBaseUrl(): string {
if (process.env.NEXT_PUBLIC_APP_URL) {
return process.env.NEXT_PUBLIC_APP_URL;
}
try {
const headersList = headers();
const host = headersList.get('host');
const protocol = headersList.get('x-forwarded-proto') || 'http';
if (host) {
return `${protocol}://${host}`;
}
} catch {
// ignore
}
return 'http://localhost:3000';
}
async function getCookieHeader(): Promise<string> {
try {
const cookieStore = await cookies();
return cookieStore
.getAll()
.map((cookie) => `${cookie.name}=${cookie.value}`)
.join('; ');
} catch {
return '';
}
}
function normalizeSummary(screening: ScreeningWithRelations): ScreeningSummary {
return {
screening,
activities: screening.screening_activities || [],
documents: screening.screening_documents || [],
};
}
export async function getScreeningSummary(patientId: string): Promise<ScreeningSummary> {
try {
const baseUrl = getBaseUrl();
const cookieHeader = await getCookieHeader();
const response = await fetch(`${baseUrl}/api/screenings?patientId=${patientId}`, {
cache: 'no-store',
headers: {
...(cookieHeader && { Cookie: cookieHeader }),
},
});
if (!response.ok) {
const error = await response.text();
throw new Error(error || 'Kon screening niet ophalen');
}
const data = await response.json();
return normalizeSummary(data.screening);
} catch (error) {
console.error('Error fetching screening summary:', error);
throw error instanceof Error ? error : new Error('Kon screening niet ophalen');
}
}
export async function saveHelpRequest(params: {
patientId: string;
screeningId: string;
request: string;
}) {
try {
const baseUrl = getBaseUrl();
const cookieHeader = await getCookieHeader();
const response = await fetch(`${baseUrl}/api/screenings/${params.screeningId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...(cookieHeader && { Cookie: cookieHeader }),
},
body: JSON.stringify({ request_for_help: params.request }),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Opslaan mislukt');
}
revalidatePath(`/epd/patients/${params.patientId}/screening`);
return true;
} catch (error) {
console.error('Error saving help request:', error);
throw error instanceof Error ? error : new Error('Opslaan mislukt');
}
}
export async function saveScreeningDecision(params: {
patientId: string;
screeningId: string;
decision: 'geschikt' | 'niet_geschikt';
notes?: string;
department?: string;
}) {
try {
const baseUrl = getBaseUrl();
const cookieHeader = await getCookieHeader();
const response = await fetch(`${baseUrl}/api/screenings/${params.screeningId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...(cookieHeader && { Cookie: cookieHeader }),
},
body: JSON.stringify({
decision: params.decision,
decision_notes: params.notes,
decision_department: params.department,
}),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Opslaan mislukt');
}
revalidatePath(`/epd/patients/${params.patientId}/screening`);
revalidatePath(`/epd/patients/${params.patientId}`);
return true;
} catch (error) {
console.error('Error saving decision:', error);
throw error instanceof Error ? error : new Error('Opslaan mislukt');
}
}
export async function addScreeningActivity(params: {
patientId: string;
screeningId: string;
text: string;
}) {
try {
const baseUrl = getBaseUrl();
const cookieHeader = await getCookieHeader();
const response = await fetch(`${baseUrl}/api/screenings/${params.screeningId}/activities`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(cookieHeader && { Cookie: cookieHeader }),
},
body: JSON.stringify({ activity_text: params.text }),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Toevoegen mislukt');
}
revalidatePath(`/epd/patients/${params.patientId}/screening`);
return true;
} catch (error) {
console.error('Error adding activity:', error);
throw error instanceof Error ? error : new Error('Toevoegen mislukt');
}
}

View File

@@ -0,0 +1,105 @@
'use client';
import { useState, useTransition } from 'react';
import { addScreeningActivity } from '../actions';
import type { ScreeningActivity } from '@/lib/types/screening';
import { Loader2, MessageSquare } from 'lucide-react';
import { format } from 'date-fns';
import { nl } from 'date-fns/locale';
interface ActivityLogProps {
patientId: string;
screeningId: string;
activities: ScreeningActivity[];
}
export function ActivityLog({ patientId, screeningId, activities }: ActivityLogProps) {
const [text, setText] = useState('');
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const handleSubmit = () => {
if (!text.trim()) {
setError('Vul eerst een activiteit in.');
return;
}
setError(null);
startTransition(async () => {
try {
await addScreeningActivity({
patientId,
screeningId,
text,
});
setText('');
} catch (err) {
console.error(err);
setError(err instanceof Error ? err.message : 'Toevoegen mislukt');
}
});
};
return (
<section className="bg-white rounded-lg border border-slate-200 p-6 shadow-sm h-full flex flex-col">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-md bg-blue-50 text-blue-600">
<MessageSquare className="h-5 w-5" />
</div>
<div>
<h3 className="text-lg font-semibold text-slate-900">Activiteitenlog</h3>
<p className="text-sm text-slate-500">Chronologisch overzicht</p>
</div>
</div>
<div className="space-y-4 overflow-y-auto flex-1 pr-1">
{activities.length === 0 && (
<p className="text-sm text-slate-500">
Nog geen activiteiten. Voeg de eerste notitie toe om het dossier te starten.
</p>
)}
{activities.map((activity) => (
<div
key={activity.id}
className="border border-slate-200 rounded-lg p-3 bg-slate-50"
>
<div className="flex items-center justify-between text-xs text-slate-500 mb-1">
<span className="font-medium text-slate-700">
{activity.created_by_name || 'Onbekende gebruiker'}
</span>
{activity.created_at && (
<span>
{format(new Date(activity.created_at), 'd MMM yyyy HH:mm', { locale: nl })}
</span>
)}
</div>
<p className="text-sm text-slate-700 whitespace-pre-line">{activity.activity_text}</p>
</div>
))}
</div>
<div className="mt-4 pt-4 border-t border-slate-100">
<label className="text-sm font-medium text-slate-700 mb-2 block">
Nieuwe activiteit
</label>
<textarea
value={text}
onChange={(event) => setText(event.target.value)}
placeholder="Bijv. Huisarts gesproken, verwijsbrief ontvangen..."
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"
/>
{error && <p className="text-sm text-red-600 mt-1">{error}</p>}
<button
type="button"
onClick={handleSubmit}
disabled={isPending}
className="mt-3 inline-flex items-center gap-2 rounded-md bg-teal-600 px-4 py-2 text-sm font-medium text-white hover:bg-teal-700 disabled:opacity-60"
>
{isPending && <Loader2 className="h-4 w-4 animate-spin" />}
Toevoegen
</button>
</div>
</section>
);
}

View File

@@ -0,0 +1,186 @@
'use client';
import { useEffect, useState, useTransition } from 'react';
import { saveScreeningDecision } from '../actions';
import { cn } from '@/lib/utils';
import { ShieldCheck, ShieldX } from 'lucide-react';
const departmentOptions = [
'Volwassenen',
'Jeugd (< 18 jaar)',
'Forensisch',
'Verslaving',
'Ouderen (65+)',
'FACT',
];
interface DecisionCardProps {
patientId: string;
screeningId: string;
initialDecision?: string | null;
initialDepartment?: string | null;
initialNotes?: string | null;
hasReferralDocument: boolean;
}
export function DecisionCard({
patientId,
screeningId,
initialDecision,
initialDepartment,
initialNotes,
hasReferralDocument,
}: DecisionCardProps) {
const [decision, setDecision] = useState<'geschikt' | 'niet_geschikt' | ''>(
(initialDecision as 'geschikt' | 'niet_geschikt') || ''
);
const [department, setDepartment] = useState(initialDepartment || '');
const [notes, setNotes] = useState(initialNotes || '');
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [isPending, startTransition] = useTransition();
useEffect(() => {
setDecision((initialDecision as 'geschikt' | 'niet_geschikt') || '');
setDepartment(initialDepartment || '');
setNotes(initialNotes || '');
}, [initialDecision, initialDepartment, initialNotes]);
const handleSave = () => {
if (!decision) {
setError('Kies eerst een besluit.');
return;
}
if (decision === 'geschikt' && !department) {
setError('Kies een afdeling voor intake.');
return;
}
setError(null);
setSuccess(false);
startTransition(async () => {
try {
await saveScreeningDecision({
patientId,
screeningId,
decision,
notes,
department: decision === 'geschikt' ? department : undefined,
});
setSuccess(true);
} catch (err) {
console.error(err);
setError(err instanceof Error ? err.message : 'Opslaan mislukt');
}
});
};
return (
<section className="bg-white rounded-lg border border-slate-200 p-6 shadow-sm">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-md bg-slate-100 text-slate-700">
{decision === 'geschikt' ? (
<ShieldCheck className="h-5 w-5 text-emerald-600" />
) : decision === 'niet_geschikt' ? (
<ShieldX className="h-5 w-5 text-red-600" />
) : (
<ShieldCheck className="h-5 w-5" />
)}
</div>
<div>
<h3 className="text-lg font-semibold text-slate-900">Screeningsbesluit</h3>
<p className="text-sm text-slate-500">Alleen zichtbaar voor psychologen</p>
</div>
</div>
{!hasReferralDocument && (
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
<p className="font-medium">Geen verwijsbrief gevonden</p>
<p className="text-amber-800 mt-1">
Voeg een verwijsbrief toe voordat je een definitief besluit neemt, zodat het dossier compleet is.
</p>
</div>
)}
<div className="grid grid-cols-1 gap-4">
<div className="flex flex-wrap gap-3">
{[
{ value: 'geschikt', label: 'Geschikt voor intake' },
{ value: 'niet_geschikt', label: 'Niet geschikt / doorverwijzen' },
].map((option) => (
<button
key={option.value}
type="button"
onClick={() => {
setDecision(option.value as 'geschikt' | 'niet_geschikt');
setSuccess(false);
}}
className={cn(
'px-4 py-2 rounded-full border text-sm font-medium transition',
decision === option.value
? 'border-teal-500 bg-teal-50 text-teal-700'
: 'border-slate-200 text-slate-600 hover:border-slate-300'
)}
>
{option.label}
</button>
))}
</div>
{decision === 'geschikt' && (
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700">
Doorgaan bij afdeling
</label>
<select
value={department}
onChange={(event) => {
setDepartment(event.target.value);
setSuccess(false);
}}
className="w-full rounded-md border border-slate-200 px-3 py-2 text-sm focus:border-teal-500 focus:ring-2 focus:ring-teal-100"
>
<option value="">Selecteer afdeling...</option>
{departmentOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</div>
)}
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700">Notities bij besluit</label>
<textarea
value={notes}
onChange={(event) => {
setNotes(event.target.value);
setSuccess(false);
}}
className="w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-700 focus:bg-white focus:border-teal-500 focus:ring-2 focus:ring-teal-100"
placeholder="Extra context of afspraken..."
/>
</div>
</div>
<div className="mt-4 flex items-center justify-between text-sm">
<div>
{error && <span className="text-red-600">{error}</span>}
{success && !error && <span className="text-emerald-600">Besluit opgeslagen</span>}
</div>
<button
type="button"
onClick={handleSave}
disabled={isPending}
className="inline-flex items-center gap-2 rounded-md bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-800 disabled:opacity-60"
>
{isPending && <span className="animate-pulse">...</span>}
Opslaan besluit
</button>
</div>
</section>
);
}

View File

@@ -0,0 +1,197 @@
'use client';
import { useState, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import type { ScreeningDocument } from '@/lib/types/screening';
import { Loader2, Trash2, UploadCloud } from 'lucide-react';
import { cn } from '@/lib/utils';
const DOCUMENT_BUCKET = 'screening-documents';
const BASE_STORAGE_URL = `${process.env.NEXT_PUBLIC_SUPABASE_URL}/storage/v1/object/public/${DOCUMENT_BUCKET}`;
const documentTypeOptions = [
'verwijsbrief',
'verhuisbericht',
'indicatie',
'overig',
];
interface DocumentCardProps {
patientId: string;
screeningId: string;
documents: ScreeningDocument[];
}
export function DocumentCard({ patientId, screeningId, documents }: DocumentCardProps) {
const router = useRouter();
const [file, setFile] = useState<File | null>(null);
const [documentType, setDocumentType] = useState('verwijsbrief');
const [error, setError] = useState<string | null>(null);
const [isUploading, startUpload] = useTransition();
const [deletingId, setDeletingId] = useState<string | null>(null);
const handleUpload = () => {
if (!file) {
setError('Selecteer eerst een bestand.');
return;
}
setError(null);
startUpload(async () => {
const formData = new FormData();
formData.append('file', file);
formData.append('documentType', documentType);
try {
const response = await fetch(`/api/screenings/${screeningId}/documents`, {
method: 'POST',
body: formData,
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.error || 'Upload mislukt');
}
setFile(null);
router.refresh();
} catch (err) {
console.error(err);
setError(err instanceof Error ? err.message : 'Upload mislukt');
}
});
};
const handleDelete = async (documentId: string) => {
setDeletingId(documentId);
try {
const response = await fetch(
`/api/screenings/${screeningId}/documents/${documentId}`,
{
method: 'DELETE',
}
);
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.error || 'Verwijderen mislukt');
}
router.refresh();
} catch (err) {
console.error(err);
setError(err instanceof Error ? err.message : 'Verwijderen mislukt');
} finally {
setDeletingId(null);
}
};
return (
<section className="bg-white rounded-lg border border-slate-200 p-6 shadow-sm">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-md bg-slate-100 text-slate-600">
<UploadCloud className="h-5 w-5" />
</div>
<div>
<h3 className="text-lg font-semibold text-slate-900">Documenten</h3>
<p className="text-sm text-slate-500">
Upload verwijsbrieven, indicaties en andere stukken.
</p>
</div>
</div>
<div className="space-y-4">
<div className="border border-dashed border-slate-300 rounded-lg p-4">
<div className="flex flex-col gap-3">
<input
type="file"
onChange={(event) => {
const selected = event.target.files?.[0] ?? null;
setFile(selected);
setError(null);
}}
/>
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<select
value={documentType}
onChange={(event) => setDocumentType(event.target.value)}
className="h-10 rounded-md border border-slate-300 px-3 text-sm"
>
{documentTypeOptions.map((option) => (
<option key={option} value={option}>
{option.charAt(0).toUpperCase() + option.slice(1)}
</option>
))}
</select>
<button
type="button"
onClick={handleUpload}
disabled={isUploading}
className={cn(
'inline-flex items-center justify-center gap-2 rounded-md bg-teal-600 px-4 py-2 text-sm font-medium text-white hover:bg-teal-700 disabled:opacity-60'
)}
>
{isUploading && <Loader2 className="h-4 w-4 animate-spin" />}
Upload document
</button>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
{file && (
<p className="text-xs text-slate-500">
Geselecteerd: {file.name} {(file.size / 1024).toFixed(1)} KB
</p>
)}
</div>
</div>
<div className="space-y-3">
{documents.length === 0 && (
<p className="text-sm text-slate-500">
Nog geen documenten geregistreerd.
</p>
)}
{documents.map((doc) => {
const publicUrl = `${BASE_STORAGE_URL}/${doc.file_path}`;
return (
<div
key={doc.id}
className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 border border-slate-200 rounded-lg p-3"
>
<div>
<p className="text-sm font-medium text-slate-900">{doc.file_name}</p>
<p className="text-xs text-slate-500">
{doc.document_type} {(doc.file_size / 1024).toFixed(1)} KB {' '}
{doc.uploaded_by_name || 'Onbekend'}
</p>
</div>
<div className="flex items-center gap-3">
<a
href={publicUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-teal-600 hover:text-teal-700"
>
Download
</a>
<button
type="button"
onClick={() => handleDelete(doc.id)}
disabled={deletingId === doc.id}
className="inline-flex items-center gap-1 rounded-md border border-red-200 px-3 py-1.5 text-xs font-medium text-red-600 hover:bg-red-50 disabled:opacity-60"
>
{deletingId === doc.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
Verwijderen
</button>
</div>
</div>
);
})}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,84 @@
'use client';
import { useEffect, useState, useTransition } from 'react';
import { Loader2, NotebookPen } from 'lucide-react';
import { saveHelpRequest } from '../actions';
import { cn } from '@/lib/utils';
interface HelpRequestCardProps {
patientId: string;
screeningId: string;
initialValue?: string | null;
}
export function HelpRequestCard({ patientId, screeningId, initialValue }: HelpRequestCardProps) {
const [value, setValue] = useState(initialValue || '');
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [isPending, startTransition] = useTransition();
useEffect(() => {
setValue(initialValue || '');
}, [initialValue]);
const handleSave = () => {
setError(null);
setSuccess(false);
startTransition(async () => {
try {
await saveHelpRequest({
patientId,
screeningId,
request: value,
});
setSuccess(true);
} catch (err) {
console.error(err);
setError(err instanceof Error ? err.message : 'Opslaan mislukt');
}
});
};
return (
<section className="bg-white rounded-lg border border-slate-200 p-6 shadow-sm">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-md bg-teal-50 text-teal-600">
<NotebookPen className="h-5 w-5" />
</div>
<div>
<h3 className="text-lg font-semibold text-slate-900">Hulpvraag</h3>
<p className="text-sm text-slate-500">Beschrijf de zorgvraag van de cliënt</p>
</div>
</div>
<textarea
className="w-full min-h-[160px] rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-700 focus:bg-white focus:border-teal-500 focus:ring-2 focus:ring-teal-100 transition"
placeholder="Beschrijf de hulpvraag van de cliënt..."
value={value}
onChange={(event) => {
setValue(event.target.value);
setSuccess(false);
}}
/>
<div className="mt-4 flex items-center justify-between">
<div className="text-sm">
{error && <span className="text-red-600">{error}</span>}
{success && !error && <span className="text-emerald-600">Opgeslagen</span>}
</div>
<button
type="button"
onClick={handleSave}
disabled={isPending}
className={cn(
'inline-flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium text-white bg-teal-600 hover:bg-teal-700 transition disabled:opacity-60 disabled:cursor-not-allowed'
)}
>
{isPending && <Loader2 className="h-4 w-4 animate-spin" />}
Opslaan
</button>
</div>
</section>
);
}

View File

@@ -1,9 +1,8 @@
/**
* Screening Page
* E2.S3: Placeholder for screening functionality (to be implemented in Epic 3)
*/
import { ClipboardList } from 'lucide-react';
import { getScreeningSummary } from './actions';
import { HelpRequestCard } from './components/help-request-card';
import { DecisionCard } from './components/decision-card';
import { ActivityLog } from './components/activity-log';
import { DocumentCard } from './components/document-card';
export default async function ScreeningPage({
params,
@@ -11,29 +10,51 @@ export default async function ScreeningPage({
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const summary = await getScreeningSummary(id);
const screening = summary.screening;
const hasReferral = summary.documents.some(
(doc) => doc.document_type === 'verwijsbrief'
);
return (
<div className="p-6">
{/* Page Header */}
<div className="mb-6">
<div className="p-6 space-y-6">
<div>
<h2 className="text-lg font-semibold text-slate-900">Screening</h2>
<p className="text-sm text-slate-600 mt-1">
Activiteitenlog, documenten, hulpvraag en screeningsbesluit
</p>
</div>
{/* Placeholder */}
<div className="bg-white rounded-lg border border-slate-200 p-12 text-center">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-amber-50 mb-4">
<ClipboardList className="h-8 w-8 text-amber-500" />
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
<HelpRequestCard
patientId={id}
screeningId={screening.id}
initialValue={screening.request_for_help}
/>
<DecisionCard
patientId={id}
screeningId={screening.id}
initialDecision={screening.decision}
initialDepartment={screening.decision_department}
initialNotes={screening.decision_notes}
hasReferralDocument={hasReferral}
/>
<DocumentCard
patientId={id}
screeningId={screening.id}
documents={summary.documents}
/>
</div>
<div className="lg:col-span-1">
<ActivityLog
patientId={id}
screeningId={screening.id}
activities={summary.activities}
/>
</div>
<h3 className="text-lg font-semibold text-slate-900 mb-2">
Screening Module - Coming Soon
</h3>
<p className="text-sm text-slate-600 max-w-md mx-auto">
De screening functionaliteit wordt geïmplementeerd in Epic 3. Dit omvat
activiteitenlog, documentbeheer, hulpvraag registratie en screeningsbesluit.
</p>
</div>
</div>
);

View File

@@ -7,9 +7,49 @@
*/
import { revalidatePath } from 'next/cache';
import { headers, cookies } from 'next/headers';
import type { FHIRPatient, FHIRBundle } from '@/lib/fhir';
const API_BASE_URL = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';
/**
* Get the base URL for API calls in server actions
* Uses headers() to get the host from the request
*/
function getBaseUrl(): string {
// Try environment variable first
if (process.env.NEXT_PUBLIC_APP_URL) {
return process.env.NEXT_PUBLIC_APP_URL;
}
// Try to get from headers (works in server components/actions)
try {
const headersList = headers();
const host = headersList.get('host');
const protocol = headersList.get('x-forwarded-proto') || 'http';
if (host) {
return `${protocol}://${host}`;
}
} catch {
// Headers not available, fallback to localhost
}
// Fallback to localhost
return 'http://localhost:3000';
}
/**
* Get cookies as a string for fetch headers
*/
async function getCookieHeader(): Promise<string> {
try {
const cookieStore = await cookies();
return cookieStore
.getAll()
.map((cookie) => `${cookie.name}=${cookie.value}`)
.join('; ');
} catch {
return '';
}
}
/**
* Get all patients via FHIR API
@@ -17,31 +57,102 @@ const API_BASE_URL = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';
export async function getPatients(filters?: {
search?: string;
status?: string;
gender?: string;
page?: number;
pageSize?: number;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}) {
try {
const url = new URL(`${API_BASE_URL}/api/fhir/Patient`);
const baseUrl = getBaseUrl();
const url = new URL('/api/fhir/Patient', baseUrl);
// Search filter
if (filters?.search) {
url.searchParams.set('name', filters.search);
}
// Status filter
if (filters?.status) {
url.searchParams.set('status', filters.status);
}
// Gender filter
if (filters?.gender) {
url.searchParams.set('gender', filters.gender);
}
// Pagination (default: 50 per page)
const page = filters?.page || 1;
const pageSize = filters?.pageSize || 50;
url.searchParams.set('_count', pageSize.toString());
url.searchParams.set('_offset', ((page - 1) * pageSize).toString());
// Sorting
if (filters?.sortBy) {
const sortOrder = filters?.sortOrder === 'desc' ? '-' : '';
url.searchParams.set('_sort', `${sortOrder}${filters.sortBy}`);
}
// Get cookies to pass authentication
const cookieHeader = await getCookieHeader();
const response = await fetch(url.toString(), {
cache: 'no-store',
headers: {
'Content-Type': 'application/json',
...(cookieHeader && { Cookie: cookieHeader }),
},
});
if (!response.ok) {
throw new Error(`Failed to fetch patients: ${response.statusText}`);
const errorText = await response.text();
let errorMessage = `Failed to fetch patients: ${response.status} ${response.statusText}`;
// Check if we got HTML (likely a redirect to login)
if (errorText.trim().startsWith('<!DOCTYPE') || errorText.trim().startsWith('<html')) {
throw new Error('Niet geautoriseerd. Log opnieuw in.');
}
try {
const errorJson = JSON.parse(errorText);
if (errorJson.issue?.[0]?.diagnostics) {
errorMessage = errorJson.issue[0].diagnostics;
}
} catch {
// If not JSON, use the text (but truncate if HTML)
if (errorText && !errorText.trim().startsWith('<!')) {
errorMessage = errorText.substring(0, 200);
}
}
console.error('Error fetching patients:', {
status: response.status,
statusText: response.statusText,
url: url.toString(),
error: errorMessage.substring(0, 100),
});
throw new Error(errorMessage);
}
const bundle: FHIRBundle<FHIRPatient> = await response.json();
return bundle.entry?.map((entry) => entry.resource).filter(Boolean) as FHIRPatient[] || [];
return {
patients: bundle.entry?.map((entry) => entry.resource).filter(Boolean) as FHIRPatient[] || [],
total: bundle.total || 0,
page,
pageSize,
};
} catch (error) {
console.error('Error fetching patients:', error);
throw new Error('Failed to fetch patients');
// Provide more context in error message
if (error instanceof TypeError && error.message.includes('fetch')) {
throw new Error('Kon geen verbinding maken met de server. Controleer of de applicatie draait.');
}
throw error instanceof Error ? error : new Error('Failed to fetch patients');
}
}
@@ -50,11 +161,22 @@ export async function getPatients(filters?: {
*/
export async function getPatient(id: string) {
try {
const response = await fetch(`${API_BASE_URL}/api/fhir/Patient/${id}`, {
const baseUrl = getBaseUrl();
const cookieHeader = await getCookieHeader();
const response = await fetch(`${baseUrl}/api/fhir/Patient/${id}`, {
cache: 'no-store',
headers: {
...(cookieHeader && { Cookie: cookieHeader }),
},
});
if (!response.ok) {
const errorText = await response.text();
// Check if we got HTML (likely a redirect to login)
if (errorText.trim().startsWith('<!DOCTYPE') || errorText.trim().startsWith('<html')) {
throw new Error('Niet geautoriseerd. Log opnieuw in.');
}
throw new Error(`Failed to fetch patient: ${response.statusText}`);
}
@@ -62,7 +184,7 @@ export async function getPatient(id: string) {
return patient;
} catch (error) {
console.error('Error fetching patient:', error);
throw new Error('Failed to fetch patient');
throw error instanceof Error ? error : new Error('Failed to fetch patient');
}
}
@@ -71,10 +193,14 @@ export async function getPatient(id: string) {
*/
export async function createPatient(fhirPatient: FHIRPatient) {
try {
const response = await fetch(`${API_BASE_URL}/api/fhir/Patient`, {
const baseUrl = getBaseUrl();
const cookieHeader = await getCookieHeader();
const response = await fetch(`${baseUrl}/api/fhir/Patient`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(cookieHeader && { Cookie: cookieHeader }),
},
body: JSON.stringify(fhirPatient),
});
@@ -98,10 +224,14 @@ export async function createPatient(fhirPatient: FHIRPatient) {
*/
export async function updatePatient(id: string, fhirPatient: FHIRPatient) {
try {
const response = await fetch(`${API_BASE_URL}/api/fhir/Patient/${id}`, {
const baseUrl = getBaseUrl();
const cookieHeader = await getCookieHeader();
const response = await fetch(`${baseUrl}/api/fhir/Patient/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...(cookieHeader && { Cookie: cookieHeader }),
},
body: JSON.stringify({ ...fhirPatient, id }),
});
@@ -120,3 +250,31 @@ export async function updatePatient(id: string, fhirPatient: FHIRPatient) {
throw error instanceof Error ? error : new Error('Failed to update patient');
}
}
/**
* Delete patient via FHIR API
*/
export async function deletePatient(id: string) {
try {
const baseUrl = getBaseUrl();
const cookieHeader = await getCookieHeader();
const response = await fetch(`${baseUrl}/api/fhir/Patient/${id}`, {
method: 'DELETE',
headers: {
...(cookieHeader && { Cookie: cookieHeader }),
},
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.issue?.[0]?.diagnostics || 'Failed to delete patient');
}
revalidatePath('/epd/patients');
return { success: true };
} catch (error) {
console.error('Error deleting patient:', error);
throw error instanceof Error ? error : new Error('Failed to delete patient');
}
}

View File

@@ -0,0 +1,129 @@
'use client';
/**
* Delete Patient Button Component
* E3.S3: Delete patient with confirmation dialog
*/
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Trash2, AlertTriangle, Loader2 } from 'lucide-react';
import { deletePatient } from '../actions';
interface DeletePatientButtonProps {
patientId: string;
patientName: string;
}
export function DeletePatientButton({ patientId, patientName }: DeletePatientButtonProps) {
const router = useRouter();
const [showConfirm, setShowConfirm] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleDelete = async () => {
setIsDeleting(true);
setError(null);
try {
await deletePatient(patientId);
router.push('/epd/patients');
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : 'Fout bij verwijderen van patiënt');
setIsDeleting(false);
}
};
if (!showConfirm) {
return (
<div className="mt-8 pt-6 border-t border-slate-200">
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-red-600 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="text-sm font-semibold text-red-900 mb-1">
Gevaarlijke zone
</h3>
<p className="text-xs text-red-700 mb-3">
Het verwijderen van een patiënt kan niet ongedaan worden gemaakt. Alle gekoppelde
gegevens (intake, diagnoses, behandelplannen) worden ook verwijderd.
</p>
<button
onClick={() => setShowConfirm(true)}
className="inline-flex items-center gap-2 px-3 py-1.5 bg-red-600 hover:bg-red-700 text-white text-sm font-medium rounded-lg transition-colors"
>
<Trash2 className="h-4 w-4" />
<span>Patiënt verwijderen</span>
</button>
</div>
</div>
</div>
</div>
);
}
return (
<div className="mt-8 pt-6 border-t border-slate-200">
<div className="bg-red-50 border-2 border-red-300 rounded-lg p-6">
{error && (
<div className="bg-red-100 border border-red-300 rounded-lg p-3 mb-4">
<p className="text-sm text-red-800">{error}</p>
</div>
)}
<div className="flex items-start gap-3 mb-4">
<AlertTriangle className="h-6 w-6 text-red-600 flex-shrink-0" />
<div className="flex-1">
<h3 className="text-lg font-semibold text-red-900 mb-2">
Weet u zeker dat u deze patiënt wilt verwijderen?
</h3>
<p className="text-sm text-red-800 mb-2">
U staat op het punt om <strong>{patientName}</strong> permanent te verwijderen.
</p>
<p className="text-sm text-red-700">
Dit verwijdert:
</p>
<ul className="text-sm text-red-700 list-disc list-inside ml-2 mt-1">
<li>Alle persoonlijke gegevens</li>
<li>Alle screening en intake informatie</li>
<li>Alle diagnoses en observaties</li>
<li>Alle behandelplannen en doelen</li>
<li>Alle documenten en rapportages</li>
</ul>
<p className="text-sm font-semibold text-red-900 mt-3">
Deze actie kan niet ongedaan worden gemaakt!
</p>
</div>
</div>
<div className="flex items-center gap-3">
<button
onClick={handleDelete}
disabled={isDeleting}
className="inline-flex items-center gap-2 px-4 py-2 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isDeleting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
<span>Verwijderen...</span>
</>
) : (
<>
<Trash2 className="h-4 w-4" />
<span>Ja, verwijder permanent</span>
</>
)}
</button>
<button
onClick={() => setShowConfirm(false)}
disabled={isDeleting}
className="px-4 py-2 text-slate-700 hover:text-slate-900 font-medium transition-colors disabled:opacity-50"
>
Annuleren
</button>
</div>
</div>
</div>
);
}

View File

@@ -64,6 +64,27 @@ export function PatientForm({ patient }: PatientFormProps) {
}
}
// Extract GP (huisarts) data from extension
const gpExtension = patient?.extension?.find(
(ext) => ext.url === 'http://mini-epd.local/fhir/StructureDefinition/general-practitioner'
);
let existingGP: { name?: string; agb?: string } = {};
if (gpExtension?.valueString) {
try {
existingGP = JSON.parse(gpExtension.valueString);
} catch (e) {
console.error('Failed to parse GP extension:', e);
}
}
// Extract emergency contact data from contact field
const emergencyContact = patient?.contact?.find(
(c) => c.relationship?.some(r => r.coding?.some(code => code.code === 'C'))
);
const emergencyName = emergencyContact?.name?.text || '';
const emergencyRelationship = emergencyContact?.relationship?.[0]?.text || '';
const emergencyPhone = emergencyContact?.telecom?.find(t => t.system === 'phone')?.value || '';
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setIsSubmitting(true);
@@ -159,7 +180,49 @@ export function PatientForm({ patient }: PatientFormProps) {
}),
}
: undefined,
// GP (huisarts) extension
formData.get('gpName')
? {
url: 'http://mini-epd.local/fhir/StructureDefinition/general-practitioner',
valueString: JSON.stringify({
name: formData.get('gpName'),
agb: formData.get('gpAgb'),
}),
}
: undefined,
].filter((ext): ext is NonNullable<typeof ext> => ext !== undefined),
// Emergency contact (FHIR contact field)
contact: formData.get('emergencyName')
? [
{
relationship: [
{
coding: [
{
system: 'http://terminology.hl7.org/CodeSystem/v2-0131',
code: 'C',
display: 'Emergency Contact',
},
],
text: formData.get('emergencyRelationship') as string || 'Noodcontact',
},
],
name: {
text: formData.get('emergencyName') as string,
},
telecom: formData.get('emergencyPhone')
? [
{
system: 'phone' as const,
value: formData.get('emergencyPhone') as string,
use: 'home' as const,
},
]
: undefined,
},
]
: undefined,
};
let createdPatient: FHIRPatient;
@@ -435,6 +498,88 @@ export function PatientForm({ patient }: PatientFormProps) {
</div>
</div>
{/* General Practitioner (Huisarts) */}
<div className="space-y-4">
<h3 className="text-sm font-semibold text-slate-900 border-b pb-2">Huisarts</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label htmlFor="gpName" className="block text-sm font-medium text-slate-700 mb-1">
Naam huisarts
</label>
<input
type="text"
id="gpName"
name="gpName"
defaultValue={existingGP.name || ''}
placeholder="Dr. J. de Vries"
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-transparent"
/>
</div>
<div>
<label htmlFor="gpAgb" className="block text-sm font-medium text-slate-700 mb-1">
AGB-code huisarts
</label>
<input
type="text"
id="gpAgb"
name="gpAgb"
defaultValue={existingGP.agb || ''}
placeholder="12345678"
maxLength={8}
pattern="[0-9]{8}"
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-transparent"
/>
<p className="text-xs text-slate-500 mt-1">8 cijfers</p>
</div>
</div>
</div>
{/* Emergency Contact */}
<div className="space-y-4">
<h3 className="text-sm font-semibold text-slate-900 border-b pb-2">Noodcontact</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label htmlFor="emergencyName" className="block text-sm font-medium text-slate-700 mb-1">
Naam contactpersoon
</label>
<input
type="text"
id="emergencyName"
name="emergencyName"
defaultValue={emergencyName}
placeholder="M. Jansen"
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-transparent"
/>
</div>
<div>
<label htmlFor="emergencyRelationship" className="block text-sm font-medium text-slate-700 mb-1">
Relatie
</label>
<input
type="text"
id="emergencyRelationship"
name="emergencyRelationship"
defaultValue={emergencyRelationship}
placeholder="Partner / Ouder / Kind"
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-transparent"
/>
</div>
<div>
<label htmlFor="emergencyPhone" className="block text-sm font-medium text-slate-700 mb-1">
Telefoonnummer
</label>
<input
type="tel"
id="emergencyPhone"
name="emergencyPhone"
defaultValue={emergencyPhone}
placeholder="+31612345678"
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-transparent"
/>
</div>
</div>
</div>
{/* Action Buttons */}
<div className="flex items-center gap-4 pt-4 border-t border-slate-200">
<button

View File

@@ -2,17 +2,20 @@
/**
* Patient List Component
* E2.S1: Cliëntenlijst met zoekfunctie, filters en status badges
* E3.S2: Patiëntenlijst met zoekfunctie, filters, paginatie en sortering
*/
import { useState } from 'react';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { User, Search, Filter } from 'lucide-react';
import { User, Search, Filter, ChevronLeft, ChevronRight, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
import type { FHIRPatient } from '@/lib/fhir';
interface PatientListProps {
initialPatients: FHIRPatient[];
patients: FHIRPatient[];
total: number;
page: number;
pageSize: number;
}
// Status badge component
@@ -39,36 +42,90 @@ function StatusBadge({ status }: { status?: string }) {
);
}
export function PatientList({ initialPatients }: PatientListProps) {
export function PatientList({ patients, total, page, pageSize }: PatientListProps) {
const router = useRouter();
const searchParams = useSearchParams();
const [searchTerm, setSearchTerm] = useState(searchParams.get('search') || '');
const [statusFilter, setStatusFilter] = useState(searchParams.get('status') || 'all');
const [patients] = useState<FHIRPatient[]>(initialPatients);
const [genderFilter, setGenderFilter] = useState(searchParams.get('gender') || 'all');
const [sortBy, setSortBy] = useState(searchParams.get('sortBy') || '');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>((searchParams.get('sortOrder') as 'asc' | 'desc') || 'asc');
// Calculate pagination info
const totalPages = Math.ceil(total / pageSize);
const startIndex = (page - 1) * pageSize + 1;
const endIndex = Math.min(page * pageSize, total);
// Build URL params helper
const buildUrlParams = (updates: Record<string, string | number | undefined>) => {
const params = new URLSearchParams();
const allParams = {
search: searchTerm,
status: statusFilter,
gender: genderFilter,
sortBy: sortBy,
sortOrder: sortOrder,
page: page.toString(),
...updates,
};
Object.entries(allParams).forEach(([key, value]) => {
if (value && value !== 'all' && value !== '1') {
params.set(key, value.toString());
}
});
return params.toString();
};
// Handle search
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
const params = new URLSearchParams();
if (searchTerm) params.set('search', searchTerm);
if (statusFilter !== 'all') params.set('status', statusFilter);
router.push(`/epd/patients?${params.toString()}`);
router.push(`/epd/patients?${buildUrlParams({ page: 1 })}`);
};
// Handle status filter change
const handleStatusFilterChange = (newStatus: string) => {
setStatusFilter(newStatus);
const params = new URLSearchParams();
if (searchTerm) params.set('search', searchTerm);
if (newStatus !== 'all') params.set('status', newStatus);
router.push(`/epd/patients?${params.toString()}`);
router.push(`/epd/patients?${buildUrlParams({ status: newStatus, page: 1 })}`);
};
// Handle gender filter change
const handleGenderFilterChange = (newGender: string) => {
setGenderFilter(newGender);
router.push(`/epd/patients?${buildUrlParams({ gender: newGender, page: 1 })}`);
};
// Handle pagination
const handlePageChange = (newPage: number) => {
router.push(`/epd/patients?${buildUrlParams({ page: newPage })}`);
};
// Handle sorting
const handleSort = (column: string) => {
const newSortOrder = sortBy === column && sortOrder === 'asc' ? 'desc' : 'asc';
setSortBy(column);
setSortOrder(newSortOrder);
router.push(`/epd/patients?${buildUrlParams({ sortBy: column, sortOrder: newSortOrder, page: 1 })}`);
};
// Render sort icon
const renderSortIcon = (column: string) => {
if (sortBy !== column) {
return <ArrowUpDown className="h-4 w-4 text-slate-400" />;
}
return sortOrder === 'asc' ? (
<ArrowUp className="h-4 w-4 text-teal-600" />
) : (
<ArrowDown className="h-4 w-4 text-teal-600" />
);
};
if (patients.length === 0) {
return (
<div>
{/* Search and Filter Bar */}
<div className="mb-6 flex flex-col sm:flex-row gap-4">
<div className="mb-6 flex flex-col lg:flex-row gap-4">
{/* Search Bar */}
<form onSubmit={handleSearch} className="flex-1">
<div className="relative">
@@ -89,7 +146,7 @@ export function PatientList({ initialPatients }: PatientListProps) {
<select
value={statusFilter}
onChange={(e) => handleStatusFilterChange(e.target.value)}
className="pl-10 pr-10 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-transparent appearance-none bg-white cursor-pointer min-w-[180px]"
className="pl-10 pr-10 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-transparent appearance-none bg-white cursor-pointer min-w-[160px]"
>
<option value="all">Alle statussen</option>
<option value="planned">Screening</option>
@@ -98,6 +155,22 @@ export function PatientList({ initialPatients }: PatientListProps) {
<option value="cancelled">Afgemeld</option>
</select>
</div>
{/* Gender Filter */}
<div className="relative">
<Filter className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-slate-400" />
<select
value={genderFilter}
onChange={(e) => handleGenderFilterChange(e.target.value)}
className="pl-10 pr-10 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-transparent appearance-none bg-white cursor-pointer min-w-[160px]"
>
<option value="all">Alle geslachten</option>
<option value="male">Man</option>
<option value="female">Vrouw</option>
<option value="other">Anders</option>
<option value="unknown">Onbekend</option>
</select>
</div>
</div>
{/* Empty State */}
@@ -105,7 +178,7 @@ export function PatientList({ initialPatients }: PatientListProps) {
<User className="mx-auto h-12 w-12 text-slate-400" />
<h3 className="mt-4 text-lg font-medium text-slate-900">Geen patiënten gevonden</h3>
<p className="mt-2 text-sm text-slate-600">
{searchTerm || statusFilter !== 'all'
{searchTerm || statusFilter !== 'all' || genderFilter !== 'all'
? 'Probeer een andere zoekopdracht of filter.'
: 'Begin met het toevoegen van een nieuwe patiënt.'}
</p>
@@ -117,7 +190,7 @@ export function PatientList({ initialPatients }: PatientListProps) {
return (
<div>
{/* Search and Filter Bar */}
<div className="mb-6 flex flex-col sm:flex-row gap-4">
<div className="mb-6 flex flex-col lg:flex-row gap-4">
{/* Search Bar */}
<form onSubmit={handleSearch} className="flex-1">
<div className="relative">
@@ -138,7 +211,7 @@ export function PatientList({ initialPatients }: PatientListProps) {
<select
value={statusFilter}
onChange={(e) => handleStatusFilterChange(e.target.value)}
className="pl-10 pr-10 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-transparent appearance-none bg-white cursor-pointer min-w-[180px]"
className="pl-10 pr-10 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-transparent appearance-none bg-white cursor-pointer min-w-[160px]"
>
<option value="all">Alle statussen</option>
<option value="planned">Screening</option>
@@ -147,6 +220,22 @@ export function PatientList({ initialPatients }: PatientListProps) {
<option value="cancelled">Afgemeld</option>
</select>
</div>
{/* Gender Filter */}
<div className="relative">
<Filter className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-slate-400" />
<select
value={genderFilter}
onChange={(e) => handleGenderFilterChange(e.target.value)}
className="pl-10 pr-10 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-transparent appearance-none bg-white cursor-pointer min-w-[160px]"
>
<option value="all">Alle geslachten</option>
<option value="male">Man</option>
<option value="female">Vrouw</option>
<option value="other">Anders</option>
<option value="unknown">Onbekend</option>
</select>
</div>
</div>
{/* Patient Table */}
@@ -155,20 +244,44 @@ export function PatientList({ initialPatients }: PatientListProps) {
<table className="min-w-full divide-y divide-slate-200">
<thead className="bg-slate-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
Naam
<th className="px-6 py-3 text-left">
<button
onClick={() => handleSort('name')}
className="flex items-center gap-2 text-xs font-medium text-slate-500 uppercase tracking-wider hover:text-slate-700 transition-colors"
>
Naam
{renderSortIcon('name')}
</button>
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
BSN
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
Geboortedatum
<th className="px-6 py-3 text-left">
<button
onClick={() => handleSort('birthDate')}
className="flex items-center gap-2 text-xs font-medium text-slate-500 uppercase tracking-wider hover:text-slate-700 transition-colors"
>
Geboortedatum
{renderSortIcon('birthDate')}
</button>
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
Status
<th className="px-6 py-3 text-left">
<button
onClick={() => handleSort('status')}
className="flex items-center gap-2 text-xs font-medium text-slate-500 uppercase tracking-wider hover:text-slate-700 transition-colors"
>
Status
{renderSortIcon('status')}
</button>
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
Laatst gewijzigd
<th className="px-6 py-3 text-left">
<button
onClick={() => handleSort('_lastUpdated')}
className="flex items-center gap-2 text-xs font-medium text-slate-500 uppercase tracking-wider hover:text-slate-700 transition-colors"
>
Laatst gewijzigd
{renderSortIcon('_lastUpdated')}
</button>
</th>
</tr>
</thead>
@@ -247,6 +360,69 @@ export function PatientList({ initialPatients }: PatientListProps) {
</tbody>
</table>
</div>
{/* Pagination Controls */}
{totalPages > 1 && (
<div className="px-6 py-4 border-t border-slate-200 flex items-center justify-between">
{/* Results info */}
<div className="text-sm text-slate-600">
Resultaten {startIndex}-{endIndex} van {total}
</div>
{/* Pagination buttons */}
<div className="flex items-center gap-2">
<button
onClick={() => handlePageChange(page - 1)}
disabled={page === 1}
className="p-2 rounded-lg border border-slate-300 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
title="Vorige pagina"
>
<ChevronLeft className="h-4 w-4" />
</button>
{/* Page numbers */}
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(totalPages, 7) }, (_, i) => {
let pageNum;
if (totalPages <= 7) {
pageNum = i + 1;
} else if (page <= 4) {
pageNum = i + 1;
} else if (page >= totalPages - 3) {
pageNum = totalPages - 6 + i;
} else {
pageNum = page - 3 + i;
}
if (pageNum < 1 || pageNum > totalPages) return null;
return (
<button
key={pageNum}
onClick={() => handlePageChange(pageNum)}
className={`min-w-[2.5rem] px-3 py-2 rounded-lg border text-sm font-medium transition-colors ${
page === pageNum
? 'bg-teal-600 text-white border-teal-600'
: 'border-slate-300 hover:bg-slate-50'
}`}
>
{pageNum}
</button>
);
})}
</div>
<button
onClick={() => handlePageChange(page + 1)}
disabled={page === totalPages}
className="p-2 rounded-lg border border-slate-300 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
title="Volgende pagina"
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
)}
</div>
</div>
);

View File

@@ -7,6 +7,10 @@ import Link from 'next/link';
interface SearchParams {
search?: string;
status?: string;
gender?: string;
page?: string;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}
export default async function PatientsPage({
@@ -45,10 +49,16 @@ export default async function PatientsPage({
}
async function PatientListWrapper({ searchParams }: { searchParams: SearchParams }) {
const patients = await getPatients({
const page = searchParams.page ? parseInt(searchParams.page, 10) : 1;
const result = await getPatients({
search: searchParams.search,
status: searchParams.status,
gender: searchParams.gender,
page,
sortBy: searchParams.sortBy,
sortOrder: searchParams.sortOrder,
});
return <PatientList initialPatients={patients} />;
return <PatientList {...result} />;
}

View File

@@ -346,3 +346,9 @@ body {
@apply bg-background text-foreground;
}
}
.ProseMirror p.is-empty::before {
color: #475569;
opacity: 0.9;
white-space: pre-wrap;
font-style: italic;
}

View File

@@ -31,17 +31,32 @@ const inter = localFont({
display: "swap",
src: [
{
path: "../docs/fonts/source-sans-3-v18-latin-regular.woff2",
path: "../docs/fonts/roboto-v47-latin-regular.woff2",
weight: "400",
style: "normal",
},
{
path: "../docs/fonts/source-sans-3-v18-latin-600.woff2",
path: "../docs/fonts/roboto-v47-latin-500.woff2",
weight: "500",
style: "normal",
},
{
path: "../docs/fonts/roboto-v47-latin-600.woff2",
weight: "600",
style: "normal",
},
{
path: "../docs/fonts/source-sans-3-v18-latin-600italic.woff2",
path: "../docs/fonts/roboto-v47-latin-italic.woff2",
weight: "400",
style: "italic",
},
{
path: "../docs/fonts/roboto-v47-latin-500italic.woff2",
weight: "500",
style: "italic",
},
{
path: "../docs/fonts/roboto-v47-latin-600italic.woff2",
weight: "600",
style: "italic",
},

View File

@@ -93,7 +93,7 @@ export default function LoginPage() {
text: 'Account aangemaakt! Je wordt doorgestuurd...'
})
setTimeout(() => {
router.push('/epd/clients')
router.push('/epd/patients')
}, 1000)
} else {
// Email confirmation required - user needs to check inbox
@@ -126,7 +126,7 @@ export default function LoginPage() {
text: 'Dit emailadres bestaat al. Je bent nu ingelogd!'
})
setTimeout(() => {
router.push('/epd/clients')
router.push('/epd/patients')
}, 1000)
}
// Check for duplicate email error (from Auth Hook or client-side)

View File

@@ -29,7 +29,7 @@ export default function SetPasswordPage() {
try {
await updateUserPassword(password)
router.push('/epd/clients')
router.push('/epd/patients')
} catch (err: any) {
setError(err.message)
} finally {

View File

@@ -0,0 +1,113 @@
'use client';
import { useEffect } from 'react';
import { EditorContent, useEditor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Placeholder from '@tiptap/extension-placeholder';
import { Bold, Italic, List, ListOrdered, Quote } from 'lucide-react';
import { cn } from '@/lib/utils';
interface RichTextEditorProps {
value?: string;
onChange?: (value: string) => void;
placeholder?: string;
}
export function RichTextEditor({ value, onChange, placeholder }: RichTextEditorProps) {
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: false,
bulletList: { keepMarks: true },
orderedList: { keepMarks: true },
}),
Placeholder.configure({ placeholder: placeholder || 'Schrijf iets...', includeChildren: true }),
],
content: value || '',
editorProps: {
attributes: {
class:
'prose prose-sm max-w-none focus:outline-none min-h-[160px] px-3 py-2 text-slate-800 bg-white',
},
},
onUpdate({ editor }) {
onChange?.(editor.getHTML());
},
immediatelyRender: false,
});
useEffect(() => {
if (!editor) return;
const html = value || '';
if (html !== editor.getHTML()) {
editor.commands.setContent(html, false);
}
}, [value, editor]);
if (!editor) {
return <div className="rounded-lg border border-slate-200 h-32 animate-pulse bg-slate-50" />;
}
const toggle = (command: () => void) => {
editor.chain().focus();
command();
};
return (
<div className="rounded-lg border border-slate-200 overflow-hidden">
<div className="flex items-center gap-1 border-b border-slate-200 bg-slate-50 px-2 py-1.5">
<button
type="button"
onClick={() => toggle(() => editor.chain().focus().toggleBold().run())}
className={cn(
'inline-flex h-7 w-7 items-center justify-center rounded-md text-xs text-slate-600 hover:bg-white',
editor.isActive('bold') && 'bg-white text-slate-900'
)}
>
<Bold className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => toggle(() => editor.chain().focus().toggleItalic().run())}
className={cn(
'inline-flex h-7 w-7 items-center justify-center rounded-md text-xs text-slate-600 hover:bg-white',
editor.isActive('italic') && 'bg-white text-slate-900'
)}
>
<Italic className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => toggle(() => editor.chain().focus().toggleBulletList().run())}
className={cn(
'inline-flex h-7 w-7 items-center justify-center rounded-md text-xs text-slate-600 hover:bg-white',
editor.isActive('bulletList') && 'bg-white text-slate-900'
)}
>
<List className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => toggle(() => editor.chain().focus().toggleOrderedList().run())}
className={cn(
'inline-flex h-7 w-7 items-center justify-center rounded-md text-xs text-slate-600 hover:bg-white',
editor.isActive('orderedList') && 'bg-white text-slate-900'
)}
>
<ListOrdered className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => toggle(() => editor.chain().focus().toggleBlockquote().run())}
className={cn(
'inline-flex h-7 w-7 items-center justify-center rounded-md text-xs text-slate-600 hover:bg-white',
editor.isActive('blockquote') && 'bg-white text-slate-900'
)}
>
<Quote className="h-4 w-4" />
</button>
</div>
<EditorContent editor={editor} />
</div>
);
}

View File

@@ -465,7 +465,7 @@ A: BSN versleuteld, RLS policies, audit logging, AVG-compliant.
- ZIBs: https://zibs.nl/
**Project Documentatie:**
- Technisch schema: `lib/supabase/20241121_fhir_ggz_schema.sql`
- Technisch schema: `docs/archive/schemas/20241121_fhir_ggz_schema.sql` (archived)
- Bouwplan: `docs/bouwplan-pragmatisch-fhir.md`
- Datamodel details: `docs/datamodel-documentatie.md`

69
docs/api/intakes-api.md Normal file
View File

@@ -0,0 +1,69 @@
# 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

@@ -0,0 +1,382 @@
-- WARNING: This schema is for context only and is not meant to be run.
-- Table order and constraints may not be valid for execution.
CREATE TABLE public.ai_events (
id uuid NOT NULL DEFAULT gen_random_uuid(),
kind text NOT NULL CHECK (kind = ANY (ARRAY['summarize'::text, 'readability'::text, 'extract'::text, 'plan'::text])),
client_id uuid,
note_id uuid,
request jsonb NOT NULL DEFAULT '{}'::jsonb,
response jsonb NOT NULL DEFAULT '{}'::jsonb,
duration_ms integer NOT NULL DEFAULT 0 CHECK (duration_ms >= 0),
created_at timestamp with time zone NOT NULL DEFAULT now(),
CONSTRAINT ai_events_pkey PRIMARY KEY (id),
CONSTRAINT ai_events_client_id_fkey FOREIGN KEY (client_id) REFERENCES public.clients(id),
CONSTRAINT ai_events_note_id_fkey FOREIGN KEY (note_id) REFERENCES public.intake_notes(id)
);
CREATE TABLE public.anamneses (
id uuid NOT NULL DEFAULT gen_random_uuid(),
intake_id uuid NOT NULL,
anamnese_date date NOT NULL DEFAULT CURRENT_DATE,
anamnese_type text NOT NULL CHECK (anamnese_type = ANY (ARRAY['psychiatrisch'::text, 'sociaal'::text, 'medisch'::text, 'familie'::text, 'ontwikkeling'::text, 'overig'::text])),
content text NOT NULL,
notes text,
created_by uuid,
created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now(),
CONSTRAINT anamneses_pkey PRIMARY KEY (id),
CONSTRAINT anamneses_intake_id_fkey FOREIGN KEY (intake_id) REFERENCES public.intakes(id),
CONSTRAINT anamneses_created_by_fkey FOREIGN KEY (created_by) REFERENCES public.practitioners(id)
);
CREATE TABLE public.care_plans (
id uuid NOT NULL DEFAULT gen_random_uuid(),
identifier text DEFAULT (gen_random_uuid())::text UNIQUE,
status USER-DEFINED NOT NULL DEFAULT 'draft'::careplan_status,
intent text NOT NULL DEFAULT 'plan'::text,
category_code text DEFAULT 'ggz-behandelplan'::text,
category_display text DEFAULT 'GGZ Behandelplan'::text,
title text NOT NULL,
description text,
patient_id uuid NOT NULL,
encounter_id uuid,
period_start date,
period_end date,
created_date timestamp with time zone DEFAULT now(),
author_id uuid,
contributor_ids ARRAY,
care_team_ids ARRAY,
addresses_condition_ids ARRAY,
goals jsonb DEFAULT '[]'::jsonb,
activities jsonb DEFAULT '[]'::jsonb,
note text,
created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now(),
based_on_intake_id uuid,
based_on_anamneses ARRAY,
based_on_examinations ARRAY,
based_on_risk_assessments ARRAY,
CONSTRAINT care_plans_pkey PRIMARY KEY (id),
CONSTRAINT care_plans_patient_id_fkey FOREIGN KEY (patient_id) REFERENCES public.patients(id),
CONSTRAINT care_plans_encounter_id_fkey FOREIGN KEY (encounter_id) REFERENCES public.encounters(id),
CONSTRAINT care_plans_author_id_fkey FOREIGN KEY (author_id) REFERENCES public.practitioners(id),
CONSTRAINT care_plans_based_on_intake_id_fkey FOREIGN KEY (based_on_intake_id) REFERENCES public.intakes(id)
);
CREATE TABLE public.clients (
id uuid NOT NULL DEFAULT gen_random_uuid(),
first_name text NOT NULL,
last_name text NOT NULL,
birth_date date NOT NULL,
created_at timestamp with time zone NOT NULL DEFAULT now(),
updated_at timestamp with time zone NOT NULL DEFAULT now(),
CONSTRAINT clients_pkey PRIMARY KEY (id)
);
CREATE TABLE public.conditions (
id uuid NOT NULL DEFAULT gen_random_uuid(),
identifier text DEFAULT (gen_random_uuid())::text UNIQUE,
clinical_status USER-DEFINED NOT NULL DEFAULT 'active'::condition_clinical_status,
verification_status USER-DEFINED NOT NULL DEFAULT 'provisional'::condition_verification_status,
category text NOT NULL DEFAULT 'encounter-diagnosis'::text,
severity_code text,
severity_display text,
code_system text NOT NULL DEFAULT 'http://hl7.org/fhir/sid/icd-10'::text,
code_code text NOT NULL,
code_display text NOT NULL,
body_site_code text,
body_site_display text,
patient_id uuid NOT NULL,
encounter_id uuid,
onset_datetime timestamp with time zone,
onset_age integer,
abatement_datetime timestamp with time zone,
abatement_age integer,
recorded_date timestamp with time zone NOT NULL DEFAULT now(),
recorder_id uuid,
asserter_id uuid,
note text,
created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now(),
CONSTRAINT conditions_pkey PRIMARY KEY (id),
CONSTRAINT conditions_patient_id_fkey FOREIGN KEY (patient_id) REFERENCES public.patients(id),
CONSTRAINT conditions_encounter_id_fkey FOREIGN KEY (encounter_id) REFERENCES public.encounters(id),
CONSTRAINT conditions_recorder_id_fkey FOREIGN KEY (recorder_id) REFERENCES public.practitioners(id),
CONSTRAINT conditions_asserter_id_fkey FOREIGN KEY (asserter_id) REFERENCES public.practitioners(id)
);
CREATE TABLE public.demo_users (
id uuid NOT NULL DEFAULT gen_random_uuid(),
user_id uuid UNIQUE,
access_level text NOT NULL DEFAULT 'read_only'::text CHECK (access_level = ANY (ARRAY['read_only'::text, 'interactive'::text, 'presenter'::text])),
expires_at timestamp with time zone DEFAULT (now() + '90 days'::interval),
usage_count integer DEFAULT 0,
last_login_at timestamp with time zone,
created_at timestamp with time zone NOT NULL DEFAULT now(),
updated_at timestamp with time zone NOT NULL DEFAULT now(),
notes text,
CONSTRAINT demo_users_pkey PRIMARY KEY (id),
CONSTRAINT demo_users_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id)
);
CREATE TABLE public.encounters (
id uuid NOT NULL DEFAULT gen_random_uuid(),
identifier text DEFAULT (gen_random_uuid())::text UNIQUE,
status USER-DEFINED NOT NULL DEFAULT 'planned'::encounter_status,
class_code text NOT NULL,
class_display text NOT NULL,
type_code text NOT NULL,
type_display text NOT NULL,
priority_code text,
priority_display text,
patient_id uuid NOT NULL,
practitioner_id uuid,
organization_id uuid,
period_start timestamp with time zone NOT NULL,
period_end timestamp with time zone,
reason_code ARRAY,
reason_display ARRAY,
admission_source text,
discharge_disposition text,
notes text,
intake_note_id uuid,
created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now(),
intake_id uuid,
CONSTRAINT encounters_pkey PRIMARY KEY (id),
CONSTRAINT encounters_patient_id_fkey FOREIGN KEY (patient_id) REFERENCES public.patients(id),
CONSTRAINT encounters_practitioner_id_fkey FOREIGN KEY (practitioner_id) REFERENCES public.practitioners(id),
CONSTRAINT encounters_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES public.organizations(id),
CONSTRAINT encounters_intake_note_id_fkey FOREIGN KEY (intake_note_id) REFERENCES public.intake_notes(id),
CONSTRAINT encounters_intake_id_fkey FOREIGN KEY (intake_id) REFERENCES public.intakes(id)
);
CREATE TABLE public.examinations (
id uuid NOT NULL DEFAULT gen_random_uuid(),
intake_id uuid NOT NULL,
examination_date date NOT NULL DEFAULT CURRENT_DATE,
examination_type text NOT NULL CHECK (examination_type = ANY (ARRAY['bloedonderzoek'::text, 'neuropsychologisch'::text, 'psychodiagnostiek'::text, 'iq_test'::text, 'persoonlijkheid'::text, 'medisch'::text, 'overig'::text])),
performed_by text,
reason text,
findings text NOT NULL,
document_url text,
notes text,
created_by uuid,
created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now(),
CONSTRAINT examinations_pkey PRIMARY KEY (id),
CONSTRAINT examinations_intake_id_fkey FOREIGN KEY (intake_id) REFERENCES public.intakes(id),
CONSTRAINT examinations_created_by_fkey FOREIGN KEY (created_by) REFERENCES public.practitioners(id)
);
CREATE TABLE public.intake_notes (
id uuid NOT NULL DEFAULT gen_random_uuid(),
client_id uuid NOT NULL,
title text,
tag text CHECK (tag = ANY (ARRAY['Intake'::text, 'Evaluatie'::text, 'Plan'::text])),
content_json jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (content_json IS NOT NULL),
content_text text,
author uuid,
created_at timestamp with time zone NOT NULL DEFAULT now(),
updated_at timestamp with time zone NOT NULL DEFAULT now(),
CONSTRAINT intake_notes_pkey PRIMARY KEY (id),
CONSTRAINT intake_notes_client_id_fkey FOREIGN KEY (client_id) REFERENCES public.clients(id)
);
CREATE TABLE public.intakes (
id uuid NOT NULL DEFAULT gen_random_uuid(),
patient_id uuid NOT NULL,
title text NOT NULL,
department text NOT NULL,
psychologist_id uuid,
status text NOT NULL DEFAULT 'bezig'::text CHECK (status = ANY (ARRAY['bezig'::text, 'afgerond'::text])),
start_date date NOT NULL DEFAULT CURRENT_DATE,
end_date date,
notes text,
kindcheck_data jsonb DEFAULT '{}'::jsonb,
treatment_advice jsonb DEFAULT '{}'::jsonb,
created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now(),
CONSTRAINT intakes_pkey PRIMARY KEY (id),
CONSTRAINT intakes_patient_id_fkey FOREIGN KEY (patient_id) REFERENCES public.patients(id),
CONSTRAINT intakes_psychologist_id_fkey FOREIGN KEY (psychologist_id) REFERENCES public.practitioners(id)
);
CREATE TABLE public.observations (
id uuid NOT NULL DEFAULT gen_random_uuid(),
identifier text DEFAULT (gen_random_uuid())::text UNIQUE,
status USER-DEFINED NOT NULL DEFAULT 'final'::observation_status,
category text NOT NULL,
code_system text NOT NULL,
code_code text NOT NULL,
code_display text NOT NULL,
patient_id uuid NOT NULL,
encounter_id uuid,
effective_datetime timestamp with time zone NOT NULL,
issued timestamp with time zone DEFAULT now(),
performer_id uuid,
value_type text NOT NULL,
value_quantity_value numeric,
value_quantity_unit text,
value_quantity_comparator text,
value_string text,
value_boolean boolean,
value_codeable_concept jsonb,
interpretation_code text,
interpretation_display text,
note text,
body_site text,
method_code text,
method_display text,
reference_range_low numeric,
reference_range_high numeric,
reference_range_text text,
created_at timestamp with time zone DEFAULT now(),
CONSTRAINT observations_pkey PRIMARY KEY (id),
CONSTRAINT observations_patient_id_fkey FOREIGN KEY (patient_id) REFERENCES public.patients(id),
CONSTRAINT observations_encounter_id_fkey FOREIGN KEY (encounter_id) REFERENCES public.encounters(id),
CONSTRAINT observations_performer_id_fkey FOREIGN KEY (performer_id) REFERENCES public.practitioners(id)
);
CREATE TABLE public.organizations (
id uuid NOT NULL DEFAULT gen_random_uuid(),
identifier_agb text UNIQUE,
identifier_kvk text,
name text NOT NULL,
alias ARRAY,
type_code text DEFAULT 'prov'::text,
type_display text DEFAULT 'Healthcare Provider'::text,
telecom_phone text,
telecom_email text,
telecom_website text,
address_line ARRAY,
address_city text,
address_postal_code text,
address_country text DEFAULT 'NL'::text,
active boolean DEFAULT true,
created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now(),
CONSTRAINT organizations_pkey PRIMARY KEY (id)
);
CREATE TABLE public.patients (
id uuid NOT NULL DEFAULT gen_random_uuid(),
identifier_bsn text DEFAULT '999999990'::text,
identifier_client_number text,
name_family text NOT NULL,
name_given ARRAY NOT NULL,
name_prefix text,
name_use text DEFAULT 'official'::text,
birth_date date NOT NULL,
gender USER-DEFINED NOT NULL,
telecom_phone text,
telecom_email text,
address_line ARRAY,
address_city text,
address_postal_code text,
address_country text DEFAULT 'NL'::text,
insurance_company text,
insurance_number text,
emergency_contact_name text,
emergency_contact_relationship text,
emergency_contact_phone text,
active boolean DEFAULT true,
general_practitioner_name text,
general_practitioner_agb text,
created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now(),
status USER-DEFINED DEFAULT 'planned'::episode_status,
is_john_doe boolean DEFAULT false,
CONSTRAINT patients_pkey PRIMARY KEY (id)
);
CREATE TABLE public.practitioners (
id uuid NOT NULL DEFAULT gen_random_uuid(),
identifier_big text UNIQUE,
identifier_agb text,
name_prefix text,
name_given ARRAY NOT NULL,
name_family text NOT NULL,
name_suffix text,
qualification ARRAY,
telecom_phone text,
telecom_email text,
active boolean DEFAULT true,
user_id uuid,
created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now(),
CONSTRAINT practitioners_pkey PRIMARY KEY (id),
CONSTRAINT practitioners_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id)
);
CREATE TABLE public.problem_profiles (
id uuid NOT NULL DEFAULT gen_random_uuid(),
client_id uuid NOT NULL,
category text NOT NULL CHECK (category = ANY (ARRAY['stemming_depressie'::text, 'angst'::text, 'gedrag_impuls'::text, 'middelen_gebruik'::text, 'cognitief'::text, 'context_psychosociaal'::text])),
severity text NOT NULL CHECK (severity = ANY (ARRAY['laag'::text, 'middel'::text, 'hoog'::text])),
remarks text,
source_note_id uuid,
created_at timestamp with time zone NOT NULL DEFAULT now(),
updated_at timestamp with time zone NOT NULL DEFAULT now(),
CONSTRAINT problem_profiles_pkey PRIMARY KEY (id),
CONSTRAINT problem_profiles_client_id_fkey FOREIGN KEY (client_id) REFERENCES public.clients(id),
CONSTRAINT problem_profiles_source_note_id_fkey FOREIGN KEY (source_note_id) REFERENCES public.intake_notes(id)
);
CREATE TABLE public.risk_assessments (
id uuid NOT NULL DEFAULT gen_random_uuid(),
intake_id uuid NOT NULL,
assessment_date date NOT NULL DEFAULT CURRENT_DATE,
risk_type text NOT NULL CHECK (risk_type = ANY (ARRAY['suicidaliteit'::text, 'agressie'::text, 'zelfverwaarlozing'::text, 'middelenmisbruik'::text, 'verward_gedrag'::text, 'overig'::text])),
risk_level text NOT NULL CHECK (risk_level = ANY (ARRAY['laag'::text, 'gemiddeld'::text, 'hoog'::text, 'zeer_hoog'::text])),
rationale text NOT NULL,
measures text,
evaluation_date date,
notes text,
created_by uuid,
created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now(),
CONSTRAINT risk_assessments_pkey PRIMARY KEY (id),
CONSTRAINT risk_assessments_intake_id_fkey FOREIGN KEY (intake_id) REFERENCES public.intakes(id),
CONSTRAINT risk_assessments_created_by_fkey FOREIGN KEY (created_by) REFERENCES public.practitioners(id)
);
CREATE TABLE public.screening_activities (
id uuid NOT NULL DEFAULT gen_random_uuid(),
screening_id uuid NOT NULL,
activity_text text NOT NULL,
created_by uuid,
created_by_name text,
created_at timestamp with time zone DEFAULT now(),
CONSTRAINT screening_activities_pkey PRIMARY KEY (id),
CONSTRAINT screening_activities_screening_id_fkey FOREIGN KEY (screening_id) REFERENCES public.screenings(id),
CONSTRAINT screening_activities_created_by_fkey FOREIGN KEY (created_by) REFERENCES public.practitioners(id)
);
CREATE TABLE public.screening_documents (
id uuid NOT NULL DEFAULT gen_random_uuid(),
screening_id uuid NOT NULL,
file_name text NOT NULL,
file_type text,
file_size integer,
file_path text,
document_type text CHECK (document_type = ANY (ARRAY['verwijsbrief'::text, 'verhuisbericht'::text, 'indicatie'::text, 'overig'::text])),
uploaded_by uuid,
uploaded_by_name text,
uploaded_at timestamp with time zone DEFAULT now(),
CONSTRAINT screening_documents_pkey PRIMARY KEY (id),
CONSTRAINT screening_documents_screening_id_fkey FOREIGN KEY (screening_id) REFERENCES public.screenings(id),
CONSTRAINT screening_documents_uploaded_by_fkey FOREIGN KEY (uploaded_by) REFERENCES public.practitioners(id)
);
CREATE TABLE public.screenings (
id uuid NOT NULL DEFAULT gen_random_uuid(),
patient_id uuid NOT NULL,
request_for_help text,
decision text CHECK (decision = ANY (ARRAY['geschikt'::text, 'niet_geschikt'::text])),
decision_department text,
decision_notes text,
decision_date timestamp with time zone,
decision_by uuid,
created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now(),
CONSTRAINT screenings_pkey PRIMARY KEY (id),
CONSTRAINT screenings_patient_id_fkey FOREIGN KEY (patient_id) REFERENCES public.patients(id),
CONSTRAINT screenings_decision_by_fkey FOREIGN KEY (decision_by) REFERENCES public.practitioners(id)
);
CREATE TABLE public.treatment_plans (
id uuid NOT NULL DEFAULT gen_random_uuid(),
client_id uuid NOT NULL,
version integer NOT NULL DEFAULT 1 CHECK (version > 0),
status text NOT NULL DEFAULT 'concept'::text CHECK (status = ANY (ARRAY['concept'::text, 'gepubliceerd'::text])),
plan jsonb NOT NULL DEFAULT '{"doelen": [], "frequentie": "", "interventies": [], "meetmomenten": []}'::jsonb CHECK (plan ? 'doelen'::text AND plan ? 'interventies'::text AND plan ? 'frequentie'::text AND plan ? 'meetmomenten'::text),
created_by uuid,
created_at timestamp with time zone NOT NULL DEFAULT now(),
published_at timestamp with time zone,
updated_at timestamp with time zone NOT NULL DEFAULT now(),
CONSTRAINT treatment_plans_pkey PRIMARY KEY (id),
CONSTRAINT treatment_plans_client_id_fkey FOREIGN KEY (client_id) REFERENCES public.clients(id)
);

View File

@@ -0,0 +1,29 @@
# Archived Migrations & Schema Snapshots
Deze directory bevat migrations en schema snapshots die niet meer actief gebruikt worden als migrations, maar behouden blijven voor referentie.
## Schema Snapshots
Deze bestanden zijn **niet bedoeld om uit te voeren** als migrations, maar dienen als documentatie van het huidige database schema:
- **`20251122-current-db-scheme.sql`** - Snapshot van database schema (2025-11-22)
- **`20251122-supabase-scheme.sql`** - Actueel database schema snapshot (2025-11-22)
⚠️ **Waarschuwing:** Deze bestanden bevatten een WARNING dat ze niet uitgevoerd moeten worden. Ze zijn alleen voor documentatie/referentie doeleinden.
## Test Files
- **`20241115000003_test_rls_policies.sql`** - Test queries voor RLS policies verificatie (niet een echte migration)
## Waarom gearchiveerd?
Deze bestanden zijn gearchiveerd omdat:
1. Ze geen echte migrations zijn (snapshots/test files)
2. Ze niet uitgevoerd moeten worden door Supabase CLI
3. Ze alleen voor documentatie/referentie dienen
4. Ze de migrations directory vervuilen
## Actieve Migrations
Actieve migrations staan in `/supabase/migrations/` en worden uitgevoerd door Supabase CLI.

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