feat: migrate clients module to patients + add docs
This commit is contained in:
4
.eslintrc.json
Normal file
4
.eslintrc.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -44,3 +44,5 @@ next-env.d.ts
|
||||
# claude
|
||||
.claude
|
||||
.mcp.json
|
||||
|
||||
/archive/*
|
||||
52
CHANGELOG.md
Normal file
52
CHANGELOG.md
Normal 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
|
||||
|
||||
52
app/api/deepgram/transcribe/route.ts
Normal file
52
app/api/deepgram/transcribe/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
203
app/api/intakes/[intakeId]/route.ts
Normal file
203
app/api/intakes/[intakeId]/route.ts
Normal 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
151
app/api/intakes/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
121
app/api/screenings/[screeningId]/activities/route.ts
Normal file
121
app/api/screenings/[screeningId]/activities/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
128
app/api/screenings/[screeningId]/documents/route.ts
Normal file
128
app/api/screenings/[screeningId]/documents/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
177
app/api/screenings/[screeningId]/route.ts
Normal file
177
app/api/screenings/[screeningId]/route.ts
Normal 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
127
app/api/screenings/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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:', {
|
||||
|
||||
31
app/epd/_archive/clients_backup_20251122/[...path]/route.ts
Normal file
31
app/epd/_archive/clients_backup_20251122/[...path]/route.ts
Normal 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());
|
||||
}
|
||||
|
||||
10
app/epd/_archive/clients_backup_20251122/page.tsx
Normal file
10
app/epd/_archive/clients_backup_20251122/page.tsx
Normal 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');
|
||||
}
|
||||
31
app/epd/clients/[...path]/route.ts
Normal file
31
app/epd/clients/[...path]/route.ts
Normal 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());
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
415
app/epd/patients/[id]/intakes/[intakeId]/actions.ts
Normal file
415
app/epd/patients/[id]/intakes/[intakeId]/actions.ts
Normal 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));
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
23
app/epd/patients/[id]/intakes/[intakeId]/anamnese/page.tsx
Normal file
23
app/epd/patients/[id]/intakes/[intakeId]/anamnese/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')}</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
23
app/epd/patients/[id]/intakes/[intakeId]/contacts/page.tsx
Normal file
23
app/epd/patients/[id]/intakes/[intakeId]/contacts/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
23
app/epd/patients/[id]/intakes/[intakeId]/diagnosis/page.tsx
Normal file
23
app/epd/patients/[id]/intakes/[intakeId]/diagnosis/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
23
app/epd/patients/[id]/intakes/[intakeId]/kindcheck/page.tsx
Normal file
23
app/epd/patients/[id]/intakes/[intakeId]/kindcheck/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
app/epd/patients/[id]/intakes/[intakeId]/layout.tsx
Normal file
30
app/epd/patients/[id]/intakes/[intakeId]/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
53
app/epd/patients/[id]/intakes/[intakeId]/page.tsx
Normal file
53
app/epd/patients/[id]/intakes/[intakeId]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
23
app/epd/patients/[id]/intakes/[intakeId]/risk/page.tsx
Normal file
23
app/epd/patients/[id]/intakes/[intakeId]/risk/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
app/epd/patients/[id]/intakes/[intakeId]/rom/page.tsx
Normal file
28
app/epd/patients/[id]/intakes/[intakeId]/rom/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
335
app/epd/patients/[id]/intakes/actions.ts
Normal file
335
app/epd/patients/[id]/intakes/actions.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
|
||||
73
app/epd/patients/[id]/intakes/components/intake-card.tsx
Normal file
73
app/epd/patients/[id]/intakes/components/intake-card.tsx
Normal 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>→</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>
|
||||
);
|
||||
}
|
||||
51
app/epd/patients/[id]/intakes/components/intake-list.tsx
Normal file
51
app/epd/patients/[id]/intakes/components/intake-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
138
app/epd/patients/[id]/intakes/components/new-intake-form.tsx
Normal file
138
app/epd/patients/[id]/intakes/components/new-intake-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
34
app/epd/patients/[id]/intakes/new/page.tsx
Normal file
34
app/epd/patients/[id]/intakes/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
39
app/epd/patients/[id]/intakes/page.tsx
Normal file
39
app/epd/patients/[id]/intakes/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}</>;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
171
app/epd/patients/[id]/screening/actions.ts
Normal file
171
app/epd/patients/[id]/screening/actions.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
105
app/epd/patients/[id]/screening/components/activity-log.tsx
Normal file
105
app/epd/patients/[id]/screening/components/activity-log.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
186
app/epd/patients/[id]/screening/components/decision-card.tsx
Normal file
186
app/epd/patients/[id]/screening/components/decision-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
197
app/epd/patients/[id]/screening/components/document-card.tsx
Normal file
197
app/epd/patients/[id]/screening/components/document-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
129
app/epd/patients/components/delete-patient-button.tsx
Normal file
129
app/epd/patients/components/delete-patient-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
113
components/rich-text-editor.tsx
Normal file
113
components/rich-text-editor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
69
docs/api/intakes-api.md
Normal 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]/`
|
||||
382
docs/archive/migrations/20251122-supabase-scheme.sql
Normal file
382
docs/archive/migrations/20251122-supabase-scheme.sql
Normal 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)
|
||||
);
|
||||
29
docs/archive/migrations/README.md
Normal file
29
docs/archive/migrations/README.md
Normal 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
Reference in New Issue
Block a user