feat: migrate clients module to patients + add docs

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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