feat: migrate clients module to patients + add docs
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user