chore(strip): verwijder behandelplan-module
AI-generatie negeerde de geregistreerde diagnose en ernst — elk plan was generiek (reviewbevinding). Module komt terug bij de rebuild, gekoppeld aan het nieuwe datamodel. - app/epd/patients/[id]/behandelplan/ en /api/behandelplan/ verwijderd - components/behandelplan/ (view, list, forms) verwijderd - lib/ai/behandelplan-prompt.ts, lib/ai/intervention-mapping.ts, lib/types/behandelplan.ts verwijderd - behandelplan-sectie uit patientdashboard, dashboard-API en Cortex patient-dashboard-block - 'Doorzetten naar behandelplan' uit behandeladvies-formulier Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,327 +0,0 @@
|
|||||||
/**
|
|
||||||
* Behandelplan Generate API
|
|
||||||
*
|
|
||||||
* POST /api/behandelplan/generate
|
|
||||||
* Genereert een behandelplan met Claude AI
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { createClient } from '@/lib/auth/server';
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { z } from 'zod';
|
|
||||||
import {
|
|
||||||
BEHANDELPLAN_SYSTEM_PROMPT,
|
|
||||||
buildUserPrompt,
|
|
||||||
validatePlanContext,
|
|
||||||
type PlanContext,
|
|
||||||
} from '@/lib/ai/behandelplan-prompt';
|
|
||||||
import { type Severity } from '@/lib/ai/intervention-mapping';
|
|
||||||
import {
|
|
||||||
GeneratedPlanSchema,
|
|
||||||
type GeneratedPlan,
|
|
||||||
} from '@/lib/types/behandelplan';
|
|
||||||
import { createDefaultLifeDomainScores, type LifeDomainScore } from '@/lib/types/leefgebieden';
|
|
||||||
|
|
||||||
// Input validation schema
|
|
||||||
const GenerateInputSchema = z.object({
|
|
||||||
patientId: z.string().uuid('Patient ID moet een geldige UUID zijn'),
|
|
||||||
intakeId: z.string().uuid('Intake ID moet een geldige UUID zijn'),
|
|
||||||
conditionId: z.string().uuid().optional(),
|
|
||||||
extraInstructions: z.string().max(500).optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map severity_code to Severity type
|
|
||||||
*/
|
|
||||||
function mapSeverity(severityCode: string | null | undefined): Severity {
|
|
||||||
if (!severityCode) return 'middel';
|
|
||||||
|
|
||||||
const code = severityCode.toLowerCase();
|
|
||||||
if (code.includes('mild') || code.includes('laag') || code.includes('light')) {
|
|
||||||
return 'laag';
|
|
||||||
}
|
|
||||||
if (code.includes('severe') || code.includes('hoog') || code.includes('ernstig')) {
|
|
||||||
return 'hoog';
|
|
||||||
}
|
|
||||||
return 'middel';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load context from database
|
|
||||||
*/
|
|
||||||
async function loadPlanContext(
|
|
||||||
supabase: Awaited<ReturnType<typeof createClient>>,
|
|
||||||
patientId: string,
|
|
||||||
intakeId: string,
|
|
||||||
conditionId?: string
|
|
||||||
): Promise<PlanContext> {
|
|
||||||
// 1. Load intake
|
|
||||||
const { data: intake, error: intakeError } = await supabase
|
|
||||||
.from('intakes')
|
|
||||||
.select('*')
|
|
||||||
.eq('id', intakeId)
|
|
||||||
.eq('patient_id', patientId)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (intakeError || !intake) {
|
|
||||||
throw new Error(`Intake niet gevonden: ${intakeError?.message || 'niet gevonden'}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Load condition (latest for patient, or specific one)
|
|
||||||
let conditionQuery = supabase
|
|
||||||
.from('conditions')
|
|
||||||
.select('*')
|
|
||||||
.eq('patient_id', patientId);
|
|
||||||
|
|
||||||
if (conditionId) {
|
|
||||||
conditionQuery = conditionQuery.eq('id', conditionId);
|
|
||||||
} else {
|
|
||||||
conditionQuery = conditionQuery.order('recorded_date', { ascending: false }).limit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: conditions } = await conditionQuery;
|
|
||||||
const condition = conditions?.[0];
|
|
||||||
|
|
||||||
// 3. Build intake notes from available data
|
|
||||||
const intakeNotes = buildIntakeNotes(intake);
|
|
||||||
|
|
||||||
// 4. Get life domains (from intake or default)
|
|
||||||
const lifeDomains: LifeDomainScore[] =
|
|
||||||
(intake.life_domains as LifeDomainScore[] | null) ||
|
|
||||||
createDefaultLifeDomainScores();
|
|
||||||
|
|
||||||
// 5. Build context
|
|
||||||
return {
|
|
||||||
patientId,
|
|
||||||
intakeNotes,
|
|
||||||
dsmCategory: condition?.category || condition?.code_display || 'overig',
|
|
||||||
severity: mapSeverity(condition?.severity_code),
|
|
||||||
lifeDomains,
|
|
||||||
extraInstructions: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build intake notes from intake data
|
|
||||||
*/
|
|
||||||
function buildIntakeNotes(intake: Record<string, unknown>): string {
|
|
||||||
const parts: string[] = [];
|
|
||||||
|
|
||||||
if (intake.notes) {
|
|
||||||
parts.push(String(intake.notes));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (intake.treatment_advice) {
|
|
||||||
const advice = intake.treatment_advice as Record<string, unknown>;
|
|
||||||
if (advice.content) {
|
|
||||||
parts.push(`Behandeladvies: ${String(advice.content)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (intake.kindcheck_data) {
|
|
||||||
const kindcheck = intake.kindcheck_data as Record<string, unknown>;
|
|
||||||
if (kindcheck.observations) {
|
|
||||||
parts.push(`Observaties: ${String(kindcheck.observations)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return parts.join('\n\n') || 'Geen intake notities beschikbaar.';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Call Claude API
|
|
||||||
*/
|
|
||||||
async function callClaudeAPI(context: PlanContext): Promise<GeneratedPlan> {
|
|
||||||
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
||||||
if (!apiKey) {
|
|
||||||
throw new Error('ANTHROPIC_API_KEY ontbreekt in environment');
|
|
||||||
}
|
|
||||||
|
|
||||||
const userPrompt = buildUserPrompt(context);
|
|
||||||
|
|
||||||
const response = await fetch('https://api.anthropic.com/v1/messages', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'x-api-key': apiKey,
|
|
||||||
'anthropic-version': '2023-06-01',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: 'claude-sonnet-4-20250514',
|
|
||||||
max_tokens: 4096,
|
|
||||||
temperature: 0.3,
|
|
||||||
system: BEHANDELPLAN_SYSTEM_PROMPT,
|
|
||||||
messages: [{ role: 'user', content: userPrompt }],
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorBody = await response.text();
|
|
||||||
console.error('Claude API error:', errorBody);
|
|
||||||
throw new Error(`Claude API fout: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
const rawText = data?.content?.[0]?.text;
|
|
||||||
|
|
||||||
if (!rawText) {
|
|
||||||
throw new Error('Geen response van Claude API');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse JSON from response (handle potential markdown code blocks)
|
|
||||||
let jsonText = rawText.trim();
|
|
||||||
if (jsonText.startsWith('```json')) {
|
|
||||||
jsonText = jsonText.slice(7);
|
|
||||||
}
|
|
||||||
if (jsonText.startsWith('```')) {
|
|
||||||
jsonText = jsonText.slice(3);
|
|
||||||
}
|
|
||||||
if (jsonText.endsWith('```')) {
|
|
||||||
jsonText = jsonText.slice(0, -3);
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = JSON.parse(jsonText.trim());
|
|
||||||
|
|
||||||
// Validate with Zod schema
|
|
||||||
const validated = GeneratedPlanSchema.parse(parsed);
|
|
||||||
|
|
||||||
return validated;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Log AI event to database
|
|
||||||
*/
|
|
||||||
async function logAIEvent(
|
|
||||||
supabase: Awaited<ReturnType<typeof createClient>>,
|
|
||||||
kind: string,
|
|
||||||
patientId: string,
|
|
||||||
input: Record<string, unknown>,
|
|
||||||
output: Record<string, unknown>,
|
|
||||||
durationMs: number
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
await supabase.from('ai_events').insert({
|
|
||||||
kind,
|
|
||||||
patient_id: patientId,
|
|
||||||
input_data: input,
|
|
||||||
output_data: output,
|
|
||||||
duration_ms: durationMs,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
// Log but don't fail the request
|
|
||||||
console.error('Failed to log AI event:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/behandelplan/generate
|
|
||||||
*/
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
const startTime = Date.now();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const body = await request.json();
|
|
||||||
|
|
||||||
// Validate input
|
|
||||||
const result = GenerateInputSchema.safeParse(body);
|
|
||||||
if (!result.success) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: 'Validatiefout',
|
|
||||||
details: result.error.issues.map((e) => ({
|
|
||||||
field: e.path.join('.'),
|
|
||||||
message: e.message,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { patientId, intakeId, conditionId, extraInstructions } = result.data;
|
|
||||||
const supabase = await createClient();
|
|
||||||
|
|
||||||
// Load context from database
|
|
||||||
const context = await loadPlanContext(supabase, patientId, intakeId, conditionId);
|
|
||||||
|
|
||||||
// Add extra instructions if provided
|
|
||||||
if (extraInstructions) {
|
|
||||||
context.extraInstructions = extraInstructions;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate context
|
|
||||||
const validation = validatePlanContext(context);
|
|
||||||
if (!validation.valid) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: 'Onvoldoende context voor behandelplan generatie',
|
|
||||||
details: validation.errors,
|
|
||||||
},
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Call Claude API
|
|
||||||
const plan = await callClaudeAPI(context);
|
|
||||||
|
|
||||||
const durationMs = Date.now() - startTime;
|
|
||||||
|
|
||||||
// Log AI event
|
|
||||||
await logAIEvent(
|
|
||||||
supabase,
|
|
||||||
'behandelplan_generate',
|
|
||||||
patientId,
|
|
||||||
{ intakeId, conditionId, extraInstructions },
|
|
||||||
{ goalCount: plan.doelen.length, interventionCount: plan.interventies.length },
|
|
||||||
durationMs
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
plan,
|
|
||||||
meta: {
|
|
||||||
generatedAt: new Date().toISOString(),
|
|
||||||
durationMs,
|
|
||||||
context: {
|
|
||||||
dsmCategory: context.dsmCategory,
|
|
||||||
severity: context.severity,
|
|
||||||
highPriorityDomains: context.lifeDomains
|
|
||||||
.filter((d) => d.priority === 'hoog')
|
|
||||||
.map((d) => d.domain),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error generating behandelplan:', error);
|
|
||||||
|
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Onbekende fout';
|
|
||||||
|
|
||||||
// Check for specific error types
|
|
||||||
if (errorMessage.includes('Intake niet gevonden')) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: errorMessage },
|
|
||||||
{ status: 404 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (errorMessage.includes('ANTHROPIC_API_KEY')) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'AI service niet geconfigureerd' },
|
|
||||||
{ status: 503 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (errorMessage.includes('Claude API')) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'AI service tijdelijk niet beschikbaar', details: errorMessage },
|
|
||||||
{ status: 503 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: 'Fout bij genereren behandelplan',
|
|
||||||
details: process.env.NODE_ENV === 'development' ? errorMessage : undefined,
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,18 +4,11 @@ import { createClient } from '@/lib/auth/server';
|
|||||||
import { getPatient } from '@/app/epd/patients/actions';
|
import { getPatient } from '@/app/epd/patients/actions';
|
||||||
import { getIntakesByPatientId } from '@/app/epd/patients/[id]/intakes/actions';
|
import { getIntakesByPatientId } from '@/app/epd/patients/[id]/intakes/actions';
|
||||||
import { getPatientEncounters } from '@/app/epd/agenda/actions';
|
import { getPatientEncounters } from '@/app/epd/agenda/actions';
|
||||||
import { getActiveCarePlan } from '@/app/epd/patients/[id]/behandelplan/actions';
|
|
||||||
|
|
||||||
interface RouteParams {
|
interface RouteParams {
|
||||||
params: Promise<{ patientId: string }>;
|
params: Promise<{ patientId: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractHulpvraag(notes: string | null): string | null {
|
|
||||||
if (!notes) return null;
|
|
||||||
const firstSentence = notes.split(/[.!?]/)[0];
|
|
||||||
return firstSentence.length > 150 ? `${firstSentence.slice(0, 150)}...` : firstSentence;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(_request: NextRequest, { params }: RouteParams) {
|
export async function GET(_request: NextRequest, { params }: RouteParams) {
|
||||||
try {
|
try {
|
||||||
const { patientId } = await params;
|
const { patientId } = await params;
|
||||||
@@ -38,26 +31,15 @@ export async function GET(_request: NextRequest, { params }: RouteParams) {
|
|||||||
return NextResponse.json({ error: 'Patiënt niet gevonden' }, { status: 404 });
|
return NextResponse.json({ error: 'Patiënt niet gevonden' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const [intakes, encounters, carePlan] = await Promise.all([
|
const [intakes, encounters] = await Promise.all([
|
||||||
getIntakesByPatientId(patientId).catch(() => []),
|
getIntakesByPatientId(patientId).catch(() => []),
|
||||||
getPatientEncounters(patientId).catch(() => []),
|
getPatientEncounters(patientId).catch(() => []),
|
||||||
getActiveCarePlan(patientId).catch(() => null),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let hulpvraag: string | null = null;
|
|
||||||
if (carePlan?.based_on_intake_id) {
|
|
||||||
const linkedIntake = intakes.find((intake) => intake.id === carePlan.based_on_intake_id);
|
|
||||||
if (linkedIntake?.notes) {
|
|
||||||
hulpvraag = extractHulpvraag(linkedIntake.notes);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
patient,
|
patient,
|
||||||
intakes,
|
intakes,
|
||||||
encounters,
|
encounters,
|
||||||
carePlan,
|
|
||||||
hulpvraag,
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Unexpected error in GET /api/patients/[patientId]/dashboard:', error);
|
console.error('Unexpected error in GET /api/patients/[patientId]/dashboard:', error);
|
||||||
|
|||||||
@@ -1,650 +0,0 @@
|
|||||||
'use server';
|
|
||||||
|
|
||||||
import { createClient } from '@/lib/auth/server';
|
|
||||||
import { revalidatePath } from 'next/cache';
|
|
||||||
import type { GeneratedPlan, SmartGoal, Intervention, Behandelstructuur, Behandeldoel } from '@/lib/types/behandelplan';
|
|
||||||
import { transformFromFlat } from '@/lib/types/behandelplan';
|
|
||||||
import type { LifeDomainScore } from '@/lib/types/leefgebieden';
|
|
||||||
import type { Json } from '@/lib/supabase/database.types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get care plans for a patient
|
|
||||||
*/
|
|
||||||
export async function getCarePlans(patientId: string) {
|
|
||||||
const supabase = await createClient();
|
|
||||||
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('care_plans')
|
|
||||||
.select('*')
|
|
||||||
.eq('patient_id', patientId)
|
|
||||||
.order('created_at', { ascending: false });
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error('Error fetching care plans:', error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the latest active care plan for a patient
|
|
||||||
*/
|
|
||||||
export async function getActiveCarePlan(patientId: string) {
|
|
||||||
const supabase = await createClient();
|
|
||||||
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('care_plans')
|
|
||||||
.select('*')
|
|
||||||
.eq('patient_id', patientId)
|
|
||||||
.in('status', ['draft', 'active'])
|
|
||||||
.order('version', { ascending: false })
|
|
||||||
.limit(1)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (error && error.code !== 'PGRST116') {
|
|
||||||
console.error('Error fetching active care plan:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get intakes for a patient (to select for plan generation)
|
|
||||||
*/
|
|
||||||
export async function getPatientIntakes(patientId: string) {
|
|
||||||
const supabase = await createClient();
|
|
||||||
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('intakes')
|
|
||||||
.select('id, title, status, start_date, life_domains, notes')
|
|
||||||
.eq('patient_id', patientId)
|
|
||||||
.order('start_date', { ascending: false });
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error('Error fetching intakes:', error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get conditions for a patient
|
|
||||||
*/
|
|
||||||
export async function getPatientConditions(patientId: string) {
|
|
||||||
const supabase = await createClient();
|
|
||||||
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('conditions')
|
|
||||||
.select('id, category, code_display, severity_code, severity_display, recorded_date')
|
|
||||||
.eq('patient_id', patientId)
|
|
||||||
.order('recorded_date', { ascending: false });
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error('Error fetching conditions:', error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new care plan from generated plan
|
|
||||||
*/
|
|
||||||
export async function createCarePlan(
|
|
||||||
patientId: string,
|
|
||||||
intakeId: string,
|
|
||||||
generatedPlan: GeneratedPlan,
|
|
||||||
title: string = 'Behandelplan'
|
|
||||||
) {
|
|
||||||
const supabase = await createClient();
|
|
||||||
|
|
||||||
// Get current version
|
|
||||||
const { data: existing } = await supabase
|
|
||||||
.from('care_plans')
|
|
||||||
.select('version')
|
|
||||||
.eq('patient_id', patientId)
|
|
||||||
.order('version', { ascending: false })
|
|
||||||
.limit(1)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
const nextVersion = (existing?.version || 0) + 1;
|
|
||||||
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('care_plans')
|
|
||||||
.insert({
|
|
||||||
patient_id: patientId,
|
|
||||||
based_on_intake_id: intakeId,
|
|
||||||
title: `${title} v${nextVersion}`,
|
|
||||||
status: 'draft',
|
|
||||||
intent: 'plan',
|
|
||||||
version: nextVersion,
|
|
||||||
goals: generatedPlan.doelen as unknown as Json,
|
|
||||||
activities: generatedPlan.interventies as unknown as Json,
|
|
||||||
behandelstructuur: generatedPlan.behandelstructuur as unknown as Json,
|
|
||||||
evaluatiemomenten: generatedPlan.evaluatiemomenten as unknown as Json,
|
|
||||||
sessie_planning: generatedPlan.sessiePlanning as unknown as Json,
|
|
||||||
veiligheidsplan: (generatedPlan.veiligheidsplan || null) as unknown as Json,
|
|
||||||
period_start: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
.select()
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error('Error creating care plan:', error);
|
|
||||||
throw new Error('Kon behandelplan niet opslaan');
|
|
||||||
}
|
|
||||||
|
|
||||||
revalidatePath(`/epd/patients/${patientId}/behandelplan`);
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update care plan status
|
|
||||||
*/
|
|
||||||
export async function updateCarePlanStatus(
|
|
||||||
carePlanId: string,
|
|
||||||
patientId: string,
|
|
||||||
status: 'draft' | 'active' | 'on-hold' | 'completed' | 'revoked'
|
|
||||||
) {
|
|
||||||
const supabase = await createClient();
|
|
||||||
|
|
||||||
const updateData: Record<string, unknown> = { status };
|
|
||||||
|
|
||||||
// Set published_at when activating
|
|
||||||
if (status === 'active') {
|
|
||||||
updateData.published_at = new Date().toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
const { error } = await supabase
|
|
||||||
.from('care_plans')
|
|
||||||
.update(updateData)
|
|
||||||
.eq('id', carePlanId);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error('Error updating care plan status:', error);
|
|
||||||
throw new Error('Kon status niet bijwerken');
|
|
||||||
}
|
|
||||||
|
|
||||||
revalidatePath(`/epd/patients/${patientId}/behandelplan`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update care plan goals
|
|
||||||
*/
|
|
||||||
export async function updateCarePlanGoals(
|
|
||||||
carePlanId: string,
|
|
||||||
patientId: string,
|
|
||||||
goals: GeneratedPlan['doelen']
|
|
||||||
) {
|
|
||||||
const supabase = await createClient();
|
|
||||||
|
|
||||||
const { error } = await supabase
|
|
||||||
.from('care_plans')
|
|
||||||
.update({ goals: goals as unknown as Json })
|
|
||||||
.eq('id', carePlanId);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error('Error updating goals:', error);
|
|
||||||
throw new Error('Kon doelen niet bijwerken');
|
|
||||||
}
|
|
||||||
|
|
||||||
revalidatePath(`/epd/patients/${patientId}/behandelplan`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save life domains to intake
|
|
||||||
*/
|
|
||||||
export async function saveLifeDomains(
|
|
||||||
intakeId: string,
|
|
||||||
patientId: string,
|
|
||||||
lifeDomains: LifeDomainScore[]
|
|
||||||
) {
|
|
||||||
const supabase = await createClient();
|
|
||||||
|
|
||||||
const { error } = await supabase
|
|
||||||
.from('intakes')
|
|
||||||
.update({ life_domains: lifeDomains as unknown as Json })
|
|
||||||
.eq('id', intakeId);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error('Error saving life domains:', error);
|
|
||||||
throw new Error('Kon leefgebieden niet opslaan');
|
|
||||||
}
|
|
||||||
|
|
||||||
revalidatePath(`/epd/patients/${patientId}/behandelplan`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a care plan
|
|
||||||
*/
|
|
||||||
export async function deleteCarePlan(carePlanId: string, patientId: string) {
|
|
||||||
const supabase = await createClient();
|
|
||||||
|
|
||||||
const { error } = await supabase
|
|
||||||
.from('care_plans')
|
|
||||||
.delete()
|
|
||||||
.eq('id', carePlanId);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error('Error deleting care plan:', error);
|
|
||||||
throw new Error('Kon behandelplan niet verwijderen');
|
|
||||||
}
|
|
||||||
|
|
||||||
revalidatePath(`/epd/patients/${patientId}/behandelplan`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new empty/manual care plan
|
|
||||||
*/
|
|
||||||
export async function createEmptyCarePlan(
|
|
||||||
patientId: string,
|
|
||||||
intakeId?: string,
|
|
||||||
title: string = 'Behandelplan'
|
|
||||||
) {
|
|
||||||
const supabase = await createClient();
|
|
||||||
|
|
||||||
// Get current version
|
|
||||||
const { data: existing } = await supabase
|
|
||||||
.from('care_plans')
|
|
||||||
.select('version')
|
|
||||||
.eq('patient_id', patientId)
|
|
||||||
.order('version', { ascending: false })
|
|
||||||
.limit(1)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
const nextVersion = (existing?.version || 0) + 1;
|
|
||||||
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('care_plans')
|
|
||||||
.insert({
|
|
||||||
patient_id: patientId,
|
|
||||||
based_on_intake_id: intakeId || null,
|
|
||||||
title: `${title} v${nextVersion}`,
|
|
||||||
status: 'draft',
|
|
||||||
intent: 'plan',
|
|
||||||
version: nextVersion,
|
|
||||||
goals: [],
|
|
||||||
activities: [],
|
|
||||||
period_start: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
.select()
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error('Error creating empty care plan:', error);
|
|
||||||
throw new Error('Kon behandelplan niet aanmaken');
|
|
||||||
}
|
|
||||||
|
|
||||||
revalidatePath(`/epd/patients/${patientId}/behandelplan`);
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// BEHANDELSTRUCTUUR
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update behandelstructuur
|
|
||||||
*/
|
|
||||||
export async function updateBehandelstructuur(
|
|
||||||
carePlanId: string,
|
|
||||||
patientId: string,
|
|
||||||
behandelstructuur: Behandelstructuur
|
|
||||||
) {
|
|
||||||
const supabase = await createClient();
|
|
||||||
|
|
||||||
const { error } = await supabase
|
|
||||||
.from('care_plans')
|
|
||||||
.update({ behandelstructuur: behandelstructuur as unknown as Json })
|
|
||||||
.eq('id', carePlanId);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error('Error updating behandelstructuur:', error);
|
|
||||||
throw new Error('Kon behandelstructuur niet bijwerken');
|
|
||||||
}
|
|
||||||
|
|
||||||
revalidatePath(`/epd/patients/${patientId}/behandelplan`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// GOALS (DOELEN)
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add a goal to a care plan
|
|
||||||
*/
|
|
||||||
export async function addGoal(
|
|
||||||
carePlanId: string,
|
|
||||||
patientId: string,
|
|
||||||
goal: SmartGoal
|
|
||||||
) {
|
|
||||||
const supabase = await createClient();
|
|
||||||
|
|
||||||
// Get current goals
|
|
||||||
const { data: plan } = await supabase
|
|
||||||
.from('care_plans')
|
|
||||||
.select('goals')
|
|
||||||
.eq('id', carePlanId)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
const currentGoals = (plan?.goals as unknown as SmartGoal[]) || [];
|
|
||||||
const updatedGoals = [...currentGoals, goal];
|
|
||||||
|
|
||||||
const { error } = await supabase
|
|
||||||
.from('care_plans')
|
|
||||||
.update({ goals: updatedGoals as unknown as Json })
|
|
||||||
.eq('id', carePlanId);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error('Error adding goal:', error);
|
|
||||||
throw new Error('Kon doel niet toevoegen');
|
|
||||||
}
|
|
||||||
|
|
||||||
revalidatePath(`/epd/patients/${patientId}/behandelplan`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update a single goal in a care plan
|
|
||||||
*/
|
|
||||||
export async function updateGoal(
|
|
||||||
carePlanId: string,
|
|
||||||
patientId: string,
|
|
||||||
goalId: string,
|
|
||||||
updatedGoal: SmartGoal
|
|
||||||
) {
|
|
||||||
const supabase = await createClient();
|
|
||||||
|
|
||||||
// Get current goals
|
|
||||||
const { data: plan } = await supabase
|
|
||||||
.from('care_plans')
|
|
||||||
.select('goals')
|
|
||||||
.eq('id', carePlanId)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
const currentGoals = (plan?.goals as unknown as SmartGoal[]) || [];
|
|
||||||
const updatedGoals = currentGoals.map((g) =>
|
|
||||||
g.id === goalId ? updatedGoal : g
|
|
||||||
);
|
|
||||||
|
|
||||||
const { error } = await supabase
|
|
||||||
.from('care_plans')
|
|
||||||
.update({ goals: updatedGoals as unknown as Json })
|
|
||||||
.eq('id', carePlanId);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error('Error updating goal:', error);
|
|
||||||
throw new Error('Kon doel niet bijwerken');
|
|
||||||
}
|
|
||||||
|
|
||||||
revalidatePath(`/epd/patients/${patientId}/behandelplan`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a goal from a care plan
|
|
||||||
*/
|
|
||||||
export async function deleteGoal(
|
|
||||||
carePlanId: string,
|
|
||||||
patientId: string,
|
|
||||||
goalId: string
|
|
||||||
) {
|
|
||||||
const supabase = await createClient();
|
|
||||||
|
|
||||||
// Get current goals
|
|
||||||
const { data: plan } = await supabase
|
|
||||||
.from('care_plans')
|
|
||||||
.select('goals')
|
|
||||||
.eq('id', carePlanId)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
const currentGoals = (plan?.goals as unknown as SmartGoal[]) || [];
|
|
||||||
const updatedGoals = currentGoals.filter((g) => g.id !== goalId);
|
|
||||||
|
|
||||||
const { error } = await supabase
|
|
||||||
.from('care_plans')
|
|
||||||
.update({ goals: updatedGoals as unknown as Json })
|
|
||||||
.eq('id', carePlanId);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error('Error deleting goal:', error);
|
|
||||||
throw new Error('Kon doel niet verwijderen');
|
|
||||||
}
|
|
||||||
|
|
||||||
revalidatePath(`/epd/patients/${patientId}/behandelplan`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// INTERVENTIONS (INTERVENTIES)
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add an intervention to a care plan
|
|
||||||
*/
|
|
||||||
export async function addIntervention(
|
|
||||||
carePlanId: string,
|
|
||||||
patientId: string,
|
|
||||||
intervention: Intervention
|
|
||||||
) {
|
|
||||||
const supabase = await createClient();
|
|
||||||
|
|
||||||
// Get current interventions
|
|
||||||
const { data: plan } = await supabase
|
|
||||||
.from('care_plans')
|
|
||||||
.select('activities')
|
|
||||||
.eq('id', carePlanId)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
const currentInterventions = (plan?.activities as unknown as Intervention[]) || [];
|
|
||||||
const updatedInterventions = [...currentInterventions, intervention];
|
|
||||||
|
|
||||||
const { error } = await supabase
|
|
||||||
.from('care_plans')
|
|
||||||
.update({ activities: updatedInterventions as unknown as Json })
|
|
||||||
.eq('id', carePlanId);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error('Error adding intervention:', error);
|
|
||||||
throw new Error('Kon interventie niet toevoegen');
|
|
||||||
}
|
|
||||||
|
|
||||||
revalidatePath(`/epd/patients/${patientId}/behandelplan`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update a single intervention in a care plan
|
|
||||||
*/
|
|
||||||
export async function updateIntervention(
|
|
||||||
carePlanId: string,
|
|
||||||
patientId: string,
|
|
||||||
interventionId: string,
|
|
||||||
updatedIntervention: Intervention
|
|
||||||
) {
|
|
||||||
const supabase = await createClient();
|
|
||||||
|
|
||||||
// Get current interventions
|
|
||||||
const { data: plan } = await supabase
|
|
||||||
.from('care_plans')
|
|
||||||
.select('activities')
|
|
||||||
.eq('id', carePlanId)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
const currentInterventions = (plan?.activities as unknown as Intervention[]) || [];
|
|
||||||
const updatedInterventions = currentInterventions.map((i) =>
|
|
||||||
i.id === interventionId ? updatedIntervention : i
|
|
||||||
);
|
|
||||||
|
|
||||||
const { error } = await supabase
|
|
||||||
.from('care_plans')
|
|
||||||
.update({ activities: updatedInterventions as unknown as Json })
|
|
||||||
.eq('id', carePlanId);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error('Error updating intervention:', error);
|
|
||||||
throw new Error('Kon interventie niet bijwerken');
|
|
||||||
}
|
|
||||||
|
|
||||||
revalidatePath(`/epd/patients/${patientId}/behandelplan`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete an intervention from a care plan
|
|
||||||
*/
|
|
||||||
export async function deleteIntervention(
|
|
||||||
carePlanId: string,
|
|
||||||
patientId: string,
|
|
||||||
interventionId: string
|
|
||||||
) {
|
|
||||||
const supabase = await createClient();
|
|
||||||
|
|
||||||
// Get current interventions
|
|
||||||
const { data: plan } = await supabase
|
|
||||||
.from('care_plans')
|
|
||||||
.select('activities')
|
|
||||||
.eq('id', carePlanId)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
const currentInterventions = (plan?.activities as unknown as Intervention[]) || [];
|
|
||||||
const updatedInterventions = currentInterventions.filter((i) => i.id !== interventionId);
|
|
||||||
|
|
||||||
const { error } = await supabase
|
|
||||||
.from('care_plans')
|
|
||||||
.update({ activities: updatedInterventions as unknown as Json })
|
|
||||||
.eq('id', carePlanId);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error('Error deleting intervention:', error);
|
|
||||||
throw new Error('Kon interventie niet verwijderen');
|
|
||||||
}
|
|
||||||
|
|
||||||
revalidatePath(`/epd/patients/${patientId}/behandelplan`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// BEHANDELDOEL (FLAT STRUCTURE - doel met embedded interventies)
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save a behandeldoel (creates or updates)
|
|
||||||
* Transforms flat structure to goals + activities for backwards compatibility
|
|
||||||
*/
|
|
||||||
export async function saveBehandeldoel(
|
|
||||||
carePlanId: string,
|
|
||||||
patientId: string,
|
|
||||||
behandeldoel: Behandeldoel
|
|
||||||
) {
|
|
||||||
const supabase = await createClient();
|
|
||||||
|
|
||||||
// Get current plan
|
|
||||||
const { data: plan } = await supabase
|
|
||||||
.from('care_plans')
|
|
||||||
.select('goals, activities')
|
|
||||||
.eq('id', carePlanId)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
const currentGoals = (plan?.goals as unknown as SmartGoal[]) || [];
|
|
||||||
const currentActivities = (plan?.activities as unknown as Intervention[]) || [];
|
|
||||||
|
|
||||||
// Check if this is an update or a new goal
|
|
||||||
const existingGoalIndex = currentGoals.findIndex((g) => g.id === behandeldoel.id);
|
|
||||||
|
|
||||||
// Convert behandeldoel to SmartGoal
|
|
||||||
const smartGoal: SmartGoal = {
|
|
||||||
id: behandeldoel.id,
|
|
||||||
title: behandeldoel.title,
|
|
||||||
description: '', // Not used in flat structure
|
|
||||||
clientVersion: behandeldoel.clientVersion,
|
|
||||||
lifeDomain: behandeldoel.lifeDomain,
|
|
||||||
priority: 'middel', // Default
|
|
||||||
measurability: '', // Not used in flat structure
|
|
||||||
timelineWeeks: behandeldoel.endWeek,
|
|
||||||
status: behandeldoel.status,
|
|
||||||
progress: behandeldoel.progress,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update goals array
|
|
||||||
let updatedGoals: SmartGoal[];
|
|
||||||
if (existingGoalIndex >= 0) {
|
|
||||||
updatedGoals = currentGoals.map((g, i) =>
|
|
||||||
i === existingGoalIndex ? smartGoal : g
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
updatedGoals = [...currentGoals, smartGoal];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle interventions: remove old ones for this goal and add new ones
|
|
||||||
const otherActivities = currentActivities.filter(
|
|
||||||
(a) => !a.linkedGoalIds.includes(behandeldoel.id)
|
|
||||||
);
|
|
||||||
|
|
||||||
const newActivities: Intervention[] = behandeldoel.interventies.map((int) => ({
|
|
||||||
id: int.id,
|
|
||||||
name: int.name,
|
|
||||||
description: int.description,
|
|
||||||
rationale: '', // Not used in flat structure
|
|
||||||
linkedGoalIds: [behandeldoel.id],
|
|
||||||
}));
|
|
||||||
|
|
||||||
const updatedActivities = [...otherActivities, ...newActivities];
|
|
||||||
|
|
||||||
// Save to database
|
|
||||||
const { error } = await supabase
|
|
||||||
.from('care_plans')
|
|
||||||
.update({
|
|
||||||
goals: updatedGoals as unknown as Json,
|
|
||||||
activities: updatedActivities as unknown as Json,
|
|
||||||
})
|
|
||||||
.eq('id', carePlanId);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error('Error saving behandeldoel:', error);
|
|
||||||
throw new Error('Kon behandeldoel niet opslaan');
|
|
||||||
}
|
|
||||||
|
|
||||||
revalidatePath(`/epd/patients/${patientId}/behandelplan`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a behandeldoel and its linked interventions
|
|
||||||
*/
|
|
||||||
export async function deleteBehandeldoel(
|
|
||||||
carePlanId: string,
|
|
||||||
patientId: string,
|
|
||||||
doelId: string
|
|
||||||
) {
|
|
||||||
const supabase = await createClient();
|
|
||||||
|
|
||||||
// Get current plan
|
|
||||||
const { data: plan } = await supabase
|
|
||||||
.from('care_plans')
|
|
||||||
.select('goals, activities')
|
|
||||||
.eq('id', carePlanId)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
const currentGoals = (plan?.goals as unknown as SmartGoal[]) || [];
|
|
||||||
const currentActivities = (plan?.activities as unknown as Intervention[]) || [];
|
|
||||||
|
|
||||||
// Remove the goal
|
|
||||||
const updatedGoals = currentGoals.filter((g) => g.id !== doelId);
|
|
||||||
|
|
||||||
// Remove interventions linked to this goal
|
|
||||||
const updatedActivities = currentActivities.filter(
|
|
||||||
(a) => !a.linkedGoalIds.includes(doelId)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Save to database
|
|
||||||
const { error } = await supabase
|
|
||||||
.from('care_plans')
|
|
||||||
.update({
|
|
||||||
goals: updatedGoals as unknown as Json,
|
|
||||||
activities: updatedActivities as unknown as Json,
|
|
||||||
})
|
|
||||||
.eq('id', carePlanId);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error('Error deleting behandeldoel:', error);
|
|
||||||
throw new Error('Kon behandeldoel niet verwijderen');
|
|
||||||
}
|
|
||||||
|
|
||||||
revalidatePath(`/epd/patients/${patientId}/behandelplan`);
|
|
||||||
}
|
|
||||||
@@ -1,518 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useCallback, useMemo } from 'react';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { BehandelplanView, BehandelplanList } from '@/components/behandelplan';
|
|
||||||
import { BehandelplanFlat } from '@/components/behandelplan/flat';
|
|
||||||
import {
|
|
||||||
createCarePlan,
|
|
||||||
updateCarePlanStatus,
|
|
||||||
createEmptyCarePlan,
|
|
||||||
updateBehandelstructuur,
|
|
||||||
addGoal,
|
|
||||||
updateGoal,
|
|
||||||
deleteGoal,
|
|
||||||
addIntervention,
|
|
||||||
updateIntervention,
|
|
||||||
deleteIntervention,
|
|
||||||
saveBehandeldoel,
|
|
||||||
deleteBehandeldoel,
|
|
||||||
} from './actions';
|
|
||||||
import type { GeneratedPlan, SmartGoal, Intervention, Sessie, Evaluatiemoment, Veiligheidsplan, Behandelstructuur, Behandeldoel } from '@/lib/types/behandelplan';
|
|
||||||
import type { LifeDomainScore } from '@/lib/types/leefgebieden';
|
|
||||||
import type { Json } from '@/lib/supabase/database.types';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { LayoutGrid, List } from 'lucide-react';
|
|
||||||
|
|
||||||
// Database row types (what we get from Supabase)
|
|
||||||
interface DbCarePlan {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
status: string;
|
|
||||||
version: number | null;
|
|
||||||
goals: Json | null;
|
|
||||||
activities: Json | null;
|
|
||||||
behandelstructuur: Json | null;
|
|
||||||
sessie_planning: Json | null;
|
|
||||||
evaluatiemomenten: Json | null;
|
|
||||||
veiligheidsplan: Json | null;
|
|
||||||
created_at: string | null;
|
|
||||||
published_at: string | null;
|
|
||||||
period_start: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DbIntake {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
status: string;
|
|
||||||
start_date: string;
|
|
||||||
life_domains: Json | null;
|
|
||||||
notes: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DbCondition {
|
|
||||||
id: string;
|
|
||||||
category: string;
|
|
||||||
code_display: string;
|
|
||||||
severity_code: string | null;
|
|
||||||
severity_display: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mapped types for the view component
|
|
||||||
interface ViewCarePlan {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
status: 'draft' | 'active' | 'on-hold' | 'completed' | 'revoked' | 'entered-in-error' | 'unknown';
|
|
||||||
version: number | null;
|
|
||||||
goals: SmartGoal[] | null;
|
|
||||||
activities: Intervention[] | null;
|
|
||||||
behandelstructuur: GeneratedPlan['behandelstructuur'] | null;
|
|
||||||
sessie_planning: Sessie[] | null;
|
|
||||||
evaluatiemomenten: Evaluatiemoment[] | null;
|
|
||||||
veiligheidsplan: Veiligheidsplan | null;
|
|
||||||
created_at: string | null;
|
|
||||||
published_at: string | null;
|
|
||||||
period_start: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ViewIntake {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
status: string;
|
|
||||||
start_date: string;
|
|
||||||
life_domains: LifeDomainScore[] | null;
|
|
||||||
notes: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ViewCondition {
|
|
||||||
id: string;
|
|
||||||
category: string;
|
|
||||||
code_display: string;
|
|
||||||
severity_code: string | null;
|
|
||||||
severity_display: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BehandelplanPageClientProps {
|
|
||||||
patientId: string;
|
|
||||||
allPlans: DbCarePlan[];
|
|
||||||
intakes: DbIntake[];
|
|
||||||
conditions: DbCondition[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to map database types to view types
|
|
||||||
function mapCarePlan(dbPlan: DbCarePlan | null): ViewCarePlan | null {
|
|
||||||
if (!dbPlan) return null;
|
|
||||||
return {
|
|
||||||
...dbPlan,
|
|
||||||
status: dbPlan.status as ViewCarePlan['status'],
|
|
||||||
goals: dbPlan.goals as SmartGoal[] | null,
|
|
||||||
activities: dbPlan.activities as Intervention[] | null,
|
|
||||||
behandelstructuur: dbPlan.behandelstructuur as GeneratedPlan['behandelstructuur'] | null,
|
|
||||||
sessie_planning: dbPlan.sessie_planning as Sessie[] | null,
|
|
||||||
evaluatiemomenten: dbPlan.evaluatiemomenten as Evaluatiemoment[] | null,
|
|
||||||
veiligheidsplan: dbPlan.veiligheidsplan as Veiligheidsplan | null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapIntakes(dbIntakes: DbIntake[]): ViewIntake[] {
|
|
||||||
return dbIntakes.map((intake) => ({
|
|
||||||
...intake,
|
|
||||||
life_domains: intake.life_domains as LifeDomainScore[] | null,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BehandelplanPageClient({
|
|
||||||
patientId,
|
|
||||||
allPlans: initialPlans,
|
|
||||||
intakes,
|
|
||||||
conditions,
|
|
||||||
}: BehandelplanPageClientProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
// View mode toggle: 'flat' = nieuwe platte UI, 'detailed' = oude gedetailleerde UI
|
|
||||||
const [viewMode, setViewMode] = useState<'flat' | 'detailed'>('flat');
|
|
||||||
|
|
||||||
// State voor alle plannen en selectie
|
|
||||||
const [plans, setPlans] = useState<DbCarePlan[]>(initialPlans);
|
|
||||||
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(
|
|
||||||
// Selecteer standaard het nieuwste actieve/draft plan, of het eerste plan
|
|
||||||
initialPlans.find(p => p.status === 'active')?.id ||
|
|
||||||
initialPlans.find(p => p.status === 'draft')?.id ||
|
|
||||||
initialPlans[0]?.id ||
|
|
||||||
null
|
|
||||||
);
|
|
||||||
const [isCreatingNew, setIsCreatingNew] = useState(false);
|
|
||||||
const [showCreateView, setShowCreateView] = useState(false);
|
|
||||||
|
|
||||||
// Geselecteerd plan
|
|
||||||
const selectedPlan = useMemo(() => {
|
|
||||||
const plan = plans.find(p => p.id === selectedPlanId);
|
|
||||||
return plan ? mapCarePlan(plan) : null;
|
|
||||||
}, [plans, selectedPlanId]);
|
|
||||||
|
|
||||||
// Handler voor plan selectie
|
|
||||||
const handleSelectPlan = useCallback((planId: string) => {
|
|
||||||
setSelectedPlanId(planId);
|
|
||||||
setShowCreateView(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Handler voor nieuw plan aanmaken (toont create view)
|
|
||||||
const handleShowCreateView = useCallback(() => {
|
|
||||||
setSelectedPlanId(null);
|
|
||||||
setShowCreateView(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleGenerate = useCallback(
|
|
||||||
async (intakeId: string) => {
|
|
||||||
// Get the first condition if available
|
|
||||||
const conditionId = conditions[0]?.id;
|
|
||||||
|
|
||||||
// Call the generate API
|
|
||||||
const response = await fetch('/api/behandelplan/generate', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
patientId,
|
|
||||||
intakeId,
|
|
||||||
conditionId,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json();
|
|
||||||
throw new Error(error.error || 'Er ging iets mis bij het genereren');
|
|
||||||
}
|
|
||||||
|
|
||||||
const generatedPlan: GeneratedPlan = await response.json();
|
|
||||||
|
|
||||||
// Save to database via server action
|
|
||||||
const savedPlan = await createCarePlan(patientId, intakeId, generatedPlan);
|
|
||||||
|
|
||||||
// Update plans list en selecteer het nieuwe plan
|
|
||||||
const newPlan = savedPlan as DbCarePlan;
|
|
||||||
setPlans(prev => [newPlan, ...prev]);
|
|
||||||
setSelectedPlanId(newPlan.id);
|
|
||||||
setShowCreateView(false);
|
|
||||||
|
|
||||||
// Revalidate the page
|
|
||||||
router.refresh();
|
|
||||||
},
|
|
||||||
[patientId, conditions, router]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleStatusChange = useCallback(
|
|
||||||
async (status: ViewCarePlan['status']) => {
|
|
||||||
if (!selectedPlan) return;
|
|
||||||
|
|
||||||
// Only allow valid status transitions
|
|
||||||
if (!['draft', 'active', 'on-hold', 'completed', 'revoked'].includes(status)) {
|
|
||||||
throw new Error('Ongeldige status');
|
|
||||||
}
|
|
||||||
|
|
||||||
await updateCarePlanStatus(
|
|
||||||
selectedPlan.id,
|
|
||||||
patientId,
|
|
||||||
status as 'draft' | 'active' | 'on-hold' | 'completed' | 'revoked'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update plans list
|
|
||||||
setPlans(prev => prev.map(p =>
|
|
||||||
p.id === selectedPlan.id
|
|
||||||
? {
|
|
||||||
...p,
|
|
||||||
status,
|
|
||||||
published_at: status === 'active' ? new Date().toISOString() : p.published_at
|
|
||||||
}
|
|
||||||
: p
|
|
||||||
));
|
|
||||||
|
|
||||||
// Revalidate
|
|
||||||
router.refresh();
|
|
||||||
},
|
|
||||||
[selectedPlan, patientId, router]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleCreateManual = useCallback(
|
|
||||||
async (intakeId?: string) => {
|
|
||||||
setIsCreatingNew(true);
|
|
||||||
try {
|
|
||||||
// Create empty care plan via server action
|
|
||||||
const savedPlan = await createEmptyCarePlan(patientId, intakeId);
|
|
||||||
|
|
||||||
// Update plans list en selecteer het nieuwe plan
|
|
||||||
const newPlan = savedPlan as DbCarePlan;
|
|
||||||
setPlans(prev => [newPlan, ...prev]);
|
|
||||||
setSelectedPlanId(newPlan.id);
|
|
||||||
setShowCreateView(false);
|
|
||||||
|
|
||||||
// Revalidate the page
|
|
||||||
router.refresh();
|
|
||||||
} finally {
|
|
||||||
setIsCreatingNew(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[patientId, router]
|
|
||||||
);
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// EDIT HANDLERS
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
const handleUpdateBehandelstructuur = useCallback(
|
|
||||||
async (data: Behandelstructuur) => {
|
|
||||||
if (!selectedPlan) return;
|
|
||||||
|
|
||||||
await updateBehandelstructuur(selectedPlan.id, patientId, data);
|
|
||||||
|
|
||||||
// Update local state
|
|
||||||
setPlans(prev => prev.map(p =>
|
|
||||||
p.id === selectedPlan.id
|
|
||||||
? { ...p, behandelstructuur: data as unknown as Json }
|
|
||||||
: p
|
|
||||||
));
|
|
||||||
|
|
||||||
router.refresh();
|
|
||||||
},
|
|
||||||
[selectedPlan, patientId, router]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleAddGoal = useCallback(
|
|
||||||
async (goal: SmartGoal) => {
|
|
||||||
if (!selectedPlan) return;
|
|
||||||
|
|
||||||
await addGoal(selectedPlan.id, patientId, goal);
|
|
||||||
|
|
||||||
// Update local state
|
|
||||||
setPlans(prev => prev.map(p => {
|
|
||||||
if (p.id !== selectedPlan.id) return p;
|
|
||||||
const currentGoals = (p.goals as unknown as SmartGoal[]) || [];
|
|
||||||
return { ...p, goals: [...currentGoals, goal] as unknown as Json };
|
|
||||||
}));
|
|
||||||
|
|
||||||
router.refresh();
|
|
||||||
},
|
|
||||||
[selectedPlan, patientId, router]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleUpdateGoal = useCallback(
|
|
||||||
async (goalId: string, updatedGoal: SmartGoal) => {
|
|
||||||
if (!selectedPlan) return;
|
|
||||||
|
|
||||||
await updateGoal(selectedPlan.id, patientId, goalId, updatedGoal);
|
|
||||||
|
|
||||||
// Update local state
|
|
||||||
setPlans(prev => prev.map(p => {
|
|
||||||
if (p.id !== selectedPlan.id) return p;
|
|
||||||
const currentGoals = (p.goals as unknown as SmartGoal[]) || [];
|
|
||||||
const newGoals = currentGoals.map(g => g.id === goalId ? updatedGoal : g);
|
|
||||||
return { ...p, goals: newGoals as unknown as Json };
|
|
||||||
}));
|
|
||||||
|
|
||||||
router.refresh();
|
|
||||||
},
|
|
||||||
[selectedPlan, patientId, router]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDeleteGoal = useCallback(
|
|
||||||
async (goalId: string) => {
|
|
||||||
if (!selectedPlan) return;
|
|
||||||
|
|
||||||
await deleteGoal(selectedPlan.id, patientId, goalId);
|
|
||||||
|
|
||||||
// Update local state
|
|
||||||
setPlans(prev => prev.map(p => {
|
|
||||||
if (p.id !== selectedPlan.id) return p;
|
|
||||||
const currentGoals = (p.goals as unknown as SmartGoal[]) || [];
|
|
||||||
const newGoals = currentGoals.filter(g => g.id !== goalId);
|
|
||||||
return { ...p, goals: newGoals as unknown as Json };
|
|
||||||
}));
|
|
||||||
|
|
||||||
router.refresh();
|
|
||||||
},
|
|
||||||
[selectedPlan, patientId, router]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleAddIntervention = useCallback(
|
|
||||||
async (intervention: Intervention) => {
|
|
||||||
if (!selectedPlan) return;
|
|
||||||
|
|
||||||
await addIntervention(selectedPlan.id, patientId, intervention);
|
|
||||||
|
|
||||||
// Update local state
|
|
||||||
setPlans(prev => prev.map(p => {
|
|
||||||
if (p.id !== selectedPlan.id) return p;
|
|
||||||
const currentInterventions = (p.activities as unknown as Intervention[]) || [];
|
|
||||||
return { ...p, activities: [...currentInterventions, intervention] as unknown as Json };
|
|
||||||
}));
|
|
||||||
|
|
||||||
router.refresh();
|
|
||||||
},
|
|
||||||
[selectedPlan, patientId, router]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleUpdateIntervention = useCallback(
|
|
||||||
async (interventionId: string, updatedIntervention: Intervention) => {
|
|
||||||
if (!selectedPlan) return;
|
|
||||||
|
|
||||||
await updateIntervention(selectedPlan.id, patientId, interventionId, updatedIntervention);
|
|
||||||
|
|
||||||
// Update local state
|
|
||||||
setPlans(prev => prev.map(p => {
|
|
||||||
if (p.id !== selectedPlan.id) return p;
|
|
||||||
const currentInterventions = (p.activities as unknown as Intervention[]) || [];
|
|
||||||
const newInterventions = currentInterventions.map(i => i.id === interventionId ? updatedIntervention : i);
|
|
||||||
return { ...p, activities: newInterventions as unknown as Json };
|
|
||||||
}));
|
|
||||||
|
|
||||||
router.refresh();
|
|
||||||
},
|
|
||||||
[selectedPlan, patientId, router]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDeleteIntervention = useCallback(
|
|
||||||
async (interventionId: string) => {
|
|
||||||
if (!selectedPlan) return;
|
|
||||||
|
|
||||||
await deleteIntervention(selectedPlan.id, patientId, interventionId);
|
|
||||||
|
|
||||||
// Update local state
|
|
||||||
setPlans(prev => prev.map(p => {
|
|
||||||
if (p.id !== selectedPlan.id) return p;
|
|
||||||
const currentInterventions = (p.activities as unknown as Intervention[]) || [];
|
|
||||||
const newInterventions = currentInterventions.filter(i => i.id !== interventionId);
|
|
||||||
return { ...p, activities: newInterventions as unknown as Json };
|
|
||||||
}));
|
|
||||||
|
|
||||||
router.refresh();
|
|
||||||
},
|
|
||||||
[selectedPlan, patientId, router]
|
|
||||||
);
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// FLAT VIEW HANDLERS (Behandeldoel met embedded interventies)
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
const handleSaveBehandeldoel = useCallback(
|
|
||||||
async (doel: Behandeldoel) => {
|
|
||||||
if (!selectedPlan) return;
|
|
||||||
|
|
||||||
await saveBehandeldoel(selectedPlan.id, patientId, doel);
|
|
||||||
|
|
||||||
// Update local state - we need to update both goals and activities
|
|
||||||
// For now, just refresh the page to get fresh data
|
|
||||||
router.refresh();
|
|
||||||
},
|
|
||||||
[selectedPlan, patientId, router]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDeleteBehandeldoel = useCallback(
|
|
||||||
async (doelId: string) => {
|
|
||||||
if (!selectedPlan) return;
|
|
||||||
|
|
||||||
await deleteBehandeldoel(selectedPlan.id, patientId, doelId);
|
|
||||||
router.refresh();
|
|
||||||
},
|
|
||||||
[selectedPlan, patientId, router]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Get hulpvraag from first intake notes (first line/sentence)
|
|
||||||
const hulpvraag = useMemo(() => {
|
|
||||||
const firstIntake = intakes[0];
|
|
||||||
if (!firstIntake?.notes) return null;
|
|
||||||
// Get first sentence or first 150 chars
|
|
||||||
const notes = firstIntake.notes;
|
|
||||||
const firstSentence = notes.split(/[.!?]/)[0];
|
|
||||||
return firstSentence.length > 150 ? firstSentence.slice(0, 150) + '...' : firstSentence;
|
|
||||||
}, [intakes]);
|
|
||||||
|
|
||||||
// Get life domain scores from first intake
|
|
||||||
const lifeDomainScores = useMemo(() => {
|
|
||||||
const firstIntake = intakes[0];
|
|
||||||
return firstIntake?.life_domains as LifeDomainScore[] | null;
|
|
||||||
}, [intakes]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header met view toggle */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<BehandelplanList
|
|
||||||
plans={plans.map(p => ({
|
|
||||||
id: p.id,
|
|
||||||
title: p.title,
|
|
||||||
status: p.status,
|
|
||||||
version: p.version,
|
|
||||||
created_at: p.created_at,
|
|
||||||
published_at: p.published_at,
|
|
||||||
}))}
|
|
||||||
selectedPlanId={showCreateView ? null : selectedPlanId}
|
|
||||||
onSelectPlan={handleSelectPlan}
|
|
||||||
onCreateNew={handleShowCreateView}
|
|
||||||
isCreating={isCreatingNew}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* View mode toggle */}
|
|
||||||
<div className="flex items-center gap-1 border rounded-lg p-1 bg-slate-50">
|
|
||||||
<Button
|
|
||||||
variant={viewMode === 'flat' ? 'default' : 'ghost'}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setViewMode('flat')}
|
|
||||||
className="h-8 px-3"
|
|
||||||
>
|
|
||||||
<LayoutGrid className="h-4 w-4 mr-1.5" />
|
|
||||||
Compact
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={viewMode === 'detailed' ? 'default' : 'ghost'}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setViewMode('detailed')}
|
|
||||||
className="h-8 px-3"
|
|
||||||
>
|
|
||||||
<List className="h-4 w-4 mr-1.5" />
|
|
||||||
Uitgebreid
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Geselecteerd plan - Flat view */}
|
|
||||||
{viewMode === 'flat' && (
|
|
||||||
<BehandelplanFlat
|
|
||||||
patientId={patientId}
|
|
||||||
carePlan={showCreateView ? null : selectedPlan}
|
|
||||||
condition={conditions[0] || null}
|
|
||||||
hulpvraag={hulpvraag}
|
|
||||||
lifeDomainScores={lifeDomainScores}
|
|
||||||
onGenerate={async () => {
|
|
||||||
const intakeId = intakes[0]?.id;
|
|
||||||
if (intakeId) await handleGenerate(intakeId);
|
|
||||||
}}
|
|
||||||
onCreateManual={async () => {
|
|
||||||
await handleCreateManual(intakes[0]?.id);
|
|
||||||
}}
|
|
||||||
onStatusChange={handleStatusChange}
|
|
||||||
onSaveBehandeldoel={handleSaveBehandeldoel}
|
|
||||||
onDeleteBehandeldoel={handleDeleteBehandeldoel}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Geselecteerd plan - Detailed view (oude UI) */}
|
|
||||||
{viewMode === 'detailed' && (
|
|
||||||
<BehandelplanView
|
|
||||||
patientId={patientId}
|
|
||||||
carePlan={showCreateView ? null : selectedPlan}
|
|
||||||
intakes={mapIntakes(intakes)}
|
|
||||||
conditions={conditions}
|
|
||||||
onGenerate={handleGenerate}
|
|
||||||
onStatusChange={handleStatusChange}
|
|
||||||
onCreateManual={handleCreateManual}
|
|
||||||
onUpdateBehandelstructuur={handleUpdateBehandelstructuur}
|
|
||||||
onAddGoal={handleAddGoal}
|
|
||||||
onUpdateGoal={handleUpdateGoal}
|
|
||||||
onDeleteGoal={handleDeleteGoal}
|
|
||||||
onAddIntervention={handleAddIntervention}
|
|
||||||
onUpdateIntervention={handleUpdateIntervention}
|
|
||||||
onDeleteIntervention={handleDeleteIntervention}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
/**
|
|
||||||
* Behandelplan Page
|
|
||||||
* E3.S1: Server component met data loading
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { getCarePlans, getPatientIntakes, getPatientConditions } from './actions';
|
|
||||||
import { BehandelplanPageClient } from './page-client';
|
|
||||||
|
|
||||||
export default async function BehandelplanPage({
|
|
||||||
params,
|
|
||||||
}: {
|
|
||||||
params: Promise<{ id: string }>;
|
|
||||||
}) {
|
|
||||||
const { id } = await params;
|
|
||||||
|
|
||||||
// Parallel data loading - haal ALLE plannen op
|
|
||||||
const [allPlans, intakes, conditions] = await Promise.all([
|
|
||||||
getCarePlans(id),
|
|
||||||
getPatientIntakes(id),
|
|
||||||
getPatientConditions(id),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-6">
|
|
||||||
<BehandelplanPageClient
|
|
||||||
patientId={id}
|
|
||||||
allPlans={allPlans}
|
|
||||||
intakes={intakes}
|
|
||||||
conditions={conditions}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useTransition, useCallback } from 'react';
|
import { useState, useTransition, useCallback } from 'react';
|
||||||
import { saveTreatmentAdvice } from '../../actions';
|
import { saveTreatmentAdvice } from '../../actions';
|
||||||
import { Loader2, Calendar, UserCircle, ClipboardList, Share2, CheckCircle2 } from 'lucide-react';
|
import { Loader2, Calendar, UserCircle, ClipboardList, CheckCircle2 } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
const RichTextEditor = dynamic(
|
const RichTextEditor = dynamic(
|
||||||
@@ -238,20 +237,6 @@ export function TreatmentAdviceForm({ patientId, intakeId, initialData, initialD
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|||||||
@@ -18,14 +18,6 @@ import type { Intake } from '@/lib/types/intake';
|
|||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { nl } from 'date-fns/locale';
|
import { nl } from 'date-fns/locale';
|
||||||
import { getPatientEncounters } from '@/app/epd/agenda/actions';
|
import { getPatientEncounters } from '@/app/epd/agenda/actions';
|
||||||
import { getActiveCarePlan } from './behandelplan/actions';
|
|
||||||
import type { SmartGoal, Intervention, Behandelstructuur, Evaluatiemoment } from '@/lib/types/behandelplan';
|
|
||||||
|
|
||||||
function extractHulpvraag(notes: string | null): string | null {
|
|
||||||
if (!notes) return null;
|
|
||||||
const firstSentence = notes.split(/[.!?]/)[0];
|
|
||||||
return firstSentence.length > 150 ? firstSentence.slice(0, 150) + '...' : firstSentence;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function PatientDashboardPage({
|
export default async function PatientDashboardPage({
|
||||||
params,
|
params,
|
||||||
@@ -35,10 +27,9 @@ export default async function PatientDashboardPage({
|
|||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|
||||||
// Fetch all data in parallel for better performance
|
// Fetch all data in parallel for better performance
|
||||||
const [intakesResult, encountersResult, carePlanResult] = await Promise.all([
|
const [intakesResult, encountersResult] = await Promise.all([
|
||||||
getIntakesByPatientId(id).catch(() => [] as Intake[]),
|
getIntakesByPatientId(id).catch(() => [] as Intake[]),
|
||||||
getPatientEncounters(id).catch(() => []),
|
getPatientEncounters(id).catch(() => []),
|
||||||
getActiveCarePlan(id).catch(() => null),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Process intakes
|
// Process intakes
|
||||||
@@ -58,16 +49,6 @@ export default async function PatientDashboardPage({
|
|||||||
recentEncounters = recent.slice(0, 5 - upcomingEncounters.length);
|
recentEncounters = recent.slice(0, 5 - upcomingEncounters.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process care plan and get hulpvraag from already-fetched intakes
|
|
||||||
const activeCarePlan = carePlanResult;
|
|
||||||
let hulpvraag: string | null = null;
|
|
||||||
if (activeCarePlan?.based_on_intake_id) {
|
|
||||||
const linkedIntake = intakesResult.find((i: Intake) => i.id === activeCarePlan.based_on_intake_id);
|
|
||||||
if (linkedIntake?.notes) {
|
|
||||||
hulpvraag = extractHulpvraag(linkedIntake.notes);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
@@ -266,154 +247,6 @@ export default async function PatientDashboardPage({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Behandelplan Section */}
|
|
||||||
{activeCarePlan && (
|
|
||||||
<div className="bg-white rounded-lg border border-slate-200 p-6 mb-6">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h3 className="text-lg font-semibold text-slate-900">Actief Behandelplan</h3>
|
|
||||||
<Link
|
|
||||||
href={`/epd/patients/${id}/behandelplan`}
|
|
||||||
className="text-sm text-teal-600 hover:text-teal-700 font-medium"
|
|
||||||
>
|
|
||||||
Bekijk volledig plan →
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Hulpvraag */}
|
|
||||||
{hulpvraag && (
|
|
||||||
<div className="mb-4 p-3 bg-slate-50 rounded-lg">
|
|
||||||
<p className="text-xs font-medium text-slate-600 mb-1">Hulpvraag</p>
|
|
||||||
<p className="text-sm text-slate-700 italic">“{hulpvraag}”</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Behandelstructuur */}
|
|
||||||
{activeCarePlan.behandelstructuur && (
|
|
||||||
<div className="mb-4 p-3 bg-teal-50 rounded-lg">
|
|
||||||
<p className="text-xs font-medium text-teal-700 mb-2">Behandelstructuur</p>
|
|
||||||
<div className="grid grid-cols-2 gap-2 text-sm text-teal-900">
|
|
||||||
{(() => {
|
|
||||||
const bs = activeCarePlan.behandelstructuur as unknown as Behandelstructuur;
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<span className="font-medium">Duur:</span> {bs.duur}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="font-medium">Frequentie:</span> {bs.frequentie}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="font-medium">Aantal sessies:</span> {bs.aantalSessies}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="font-medium">Vorm:</span> {bs.vorm}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Doelen Overzicht */}
|
|
||||||
{activeCarePlan.goals && Array.isArray(activeCarePlan.goals) && activeCarePlan.goals.length > 0 && (
|
|
||||||
<div className="mb-4">
|
|
||||||
<p className="text-xs font-medium text-slate-600 mb-2">Doelen ({activeCarePlan.goals.length})</p>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{(activeCarePlan.goals as unknown as SmartGoal[]).slice(0, 3).map((goal) => (
|
|
||||||
<div key={goal.id} className="p-2 bg-slate-50 rounded border border-slate-200">
|
|
||||||
<div className="flex items-start justify-between mb-1">
|
|
||||||
<p className="text-sm font-medium text-slate-900">{goal.title}</p>
|
|
||||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
|
||||||
goal.status === 'bezig' ? 'bg-blue-50 text-blue-700' :
|
|
||||||
goal.status === 'gehaald' ? 'bg-green-50 text-green-700' :
|
|
||||||
'bg-slate-50 text-slate-700'
|
|
||||||
}`}>
|
|
||||||
{goal.status === 'bezig' ? 'Bezig' :
|
|
||||||
goal.status === 'gehaald' ? 'Gehaald' :
|
|
||||||
goal.status === 'niet_gestart' ? 'Niet gestart' : goal.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{goal.progress > 0 && (
|
|
||||||
<div className="mt-1">
|
|
||||||
<div className="h-1.5 bg-slate-200 rounded-full overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="h-full bg-teal-500 transition-all"
|
|
||||||
style={{ width: `${goal.progress}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-slate-500 mt-0.5">{goal.progress}% voltooid</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{activeCarePlan.goals.length > 3 && (
|
|
||||||
<p className="text-xs text-slate-500 text-center">
|
|
||||||
+{activeCarePlan.goals.length - 3} meer doelen
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Interventies Overzicht */}
|
|
||||||
{activeCarePlan.activities && Array.isArray(activeCarePlan.activities) && activeCarePlan.activities.length > 0 && (
|
|
||||||
<div className="mb-4">
|
|
||||||
<p className="text-xs font-medium text-slate-600 mb-2">Interventies ({activeCarePlan.activities.length})</p>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{(activeCarePlan.activities as unknown as Intervention[]).slice(0, 5).map((intervention) => (
|
|
||||||
<span
|
|
||||||
key={intervention.id}
|
|
||||||
className="px-2 py-1 bg-purple-50 text-purple-700 rounded text-xs font-medium"
|
|
||||||
>
|
|
||||||
{intervention.name}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{activeCarePlan.activities.length > 5 && (
|
|
||||||
<span className="px-2 py-1 bg-slate-100 text-slate-600 rounded text-xs">
|
|
||||||
+{activeCarePlan.activities.length - 5} meer
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Aankomende Evaluatiemomenten */}
|
|
||||||
{activeCarePlan.evaluatiemomenten &&
|
|
||||||
Array.isArray(activeCarePlan.evaluatiemomenten) &&
|
|
||||||
activeCarePlan.evaluatiemomenten.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-medium text-slate-600 mb-2">Aankomende evaluatiemomenten</p>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{(activeCarePlan.evaluatiemomenten as unknown as Evaluatiemoment[])
|
|
||||||
.filter((evaluatie) => evaluatie.status === 'gepland')
|
|
||||||
.slice(0, 2)
|
|
||||||
.map((evaluatie) => (
|
|
||||||
<div key={evaluatie.id} className="p-2 bg-amber-50 rounded border border-amber-200">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-amber-900">
|
|
||||||
{evaluatie.type === 'tussentijds' ? 'Tussentijdse evaluatie' :
|
|
||||||
evaluatie.type === 'eind' ? 'Eindevaluatie' : 'Crisis evaluatie'}
|
|
||||||
</p>
|
|
||||||
{evaluatie.plannedDate && (
|
|
||||||
<p className="text-xs text-amber-700 mt-0.5">
|
|
||||||
Week {evaluatie.weekNumber} • {format(new Date(evaluatie.plannedDate), 'd MMM yyyy', { locale: nl })}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className="px-2 py-0.5 bg-amber-100 text-amber-800 rounded-full text-xs font-medium">
|
|
||||||
Gepland
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Next Steps Section */}
|
{/* Next Steps Section */}
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6">
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
|
|||||||
@@ -1,125 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Plus, FileText, CheckCircle2 } from 'lucide-react';
|
|
||||||
import { FHIR_STATUS_LABELS, type FhirCarePlanStatus } from '@/lib/types/behandelplan';
|
|
||||||
|
|
||||||
interface CarePlanSummary {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
status: string;
|
|
||||||
version: number | null;
|
|
||||||
created_at: string | null;
|
|
||||||
published_at: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BehandelplanListProps {
|
|
||||||
plans: CarePlanSummary[];
|
|
||||||
selectedPlanId: string | null;
|
|
||||||
onSelectPlan: (planId: string) => void;
|
|
||||||
onCreateNew: () => void;
|
|
||||||
isCreating?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BehandelplanList({
|
|
||||||
plans,
|
|
||||||
selectedPlanId,
|
|
||||||
onSelectPlan,
|
|
||||||
onCreateNew,
|
|
||||||
isCreating = false,
|
|
||||||
}: BehandelplanListProps) {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<CardTitle className="text-base flex items-center gap-2">
|
|
||||||
<FileText className="h-4 w-4" />
|
|
||||||
Behandelplannen ({plans.length})
|
|
||||||
</CardTitle>
|
|
||||||
<Button
|
|
||||||
onClick={onCreateNew}
|
|
||||||
disabled={isCreating}
|
|
||||||
size="sm"
|
|
||||||
className="bg-indigo-600 hover:bg-indigo-700"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4 mr-1" />
|
|
||||||
Nieuw
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{plans.length === 0 ? (
|
|
||||||
<p className="text-sm text-slate-500 text-center py-4">
|
|
||||||
Nog geen behandelplannen aangemaakt
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{plans.map((plan) => {
|
|
||||||
const isSelected = plan.id === selectedPlanId;
|
|
||||||
const statusInfo = FHIR_STATUS_LABELS[plan.status as FhirCarePlanStatus] || {
|
|
||||||
label: plan.status,
|
|
||||||
color: '#6b7280',
|
|
||||||
};
|
|
||||||
const isActive = plan.status === 'active';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={plan.id}
|
|
||||||
onClick={() => onSelectPlan(plan.id)}
|
|
||||||
className={`w-full text-left p-3 rounded-lg border transition-all ${
|
|
||||||
isSelected
|
|
||||||
? 'border-indigo-500 bg-indigo-50 ring-1 ring-indigo-500'
|
|
||||||
: 'border-slate-200 hover:border-slate-300 hover:bg-slate-50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{isActive && (
|
|
||||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
|
||||||
)}
|
|
||||||
<span className={`font-medium text-sm ${isSelected ? 'text-indigo-900' : 'text-slate-900'}`}>
|
|
||||||
{plan.title}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Badge
|
|
||||||
style={{
|
|
||||||
backgroundColor: statusInfo.color,
|
|
||||||
color: 'white',
|
|
||||||
}}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
{statusInfo.label}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 mt-1 text-xs text-slate-500">
|
|
||||||
{plan.version && <span>v{plan.version}</span>}
|
|
||||||
{plan.created_at && (
|
|
||||||
<>
|
|
||||||
<span>•</span>
|
|
||||||
<span>
|
|
||||||
{new Date(plan.created_at).toLocaleDateString('nl-NL', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
year: 'numeric',
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{plan.published_at && (
|
|
||||||
<>
|
|
||||||
<span>•</span>
|
|
||||||
<span className="text-green-600">Gepubliceerd</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,162 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, ReactNode } from 'react';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Pencil, X, Check, Loader2 } from 'lucide-react';
|
|
||||||
|
|
||||||
interface EditableSectionProps {
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
icon?: ReactNode;
|
|
||||||
children: ReactNode;
|
|
||||||
editForm?: ReactNode;
|
|
||||||
onSave?: () => Promise<void>;
|
|
||||||
onCancel?: () => void;
|
|
||||||
isEditing?: boolean;
|
|
||||||
onEditChange?: (isEditing: boolean) => void;
|
|
||||||
canEdit?: boolean;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EditableSection({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
icon,
|
|
||||||
children,
|
|
||||||
editForm,
|
|
||||||
onSave,
|
|
||||||
onCancel,
|
|
||||||
isEditing: externalIsEditing,
|
|
||||||
onEditChange,
|
|
||||||
canEdit = true,
|
|
||||||
className = '',
|
|
||||||
}: EditableSectionProps) {
|
|
||||||
const [internalIsEditing, setInternalIsEditing] = useState(false);
|
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
|
||||||
|
|
||||||
// Use external or internal state
|
|
||||||
const isEditing = externalIsEditing ?? internalIsEditing;
|
|
||||||
const setIsEditing = onEditChange ?? setInternalIsEditing;
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
if (!onSave) return;
|
|
||||||
setIsSaving(true);
|
|
||||||
try {
|
|
||||||
await onSave();
|
|
||||||
setIsEditing(false);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving:', error);
|
|
||||||
} finally {
|
|
||||||
setIsSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancel = () => {
|
|
||||||
onCancel?.();
|
|
||||||
setIsEditing(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className={className}>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<CardTitle className="text-base flex items-center gap-2">
|
|
||||||
{icon}
|
|
||||||
{title}
|
|
||||||
</CardTitle>
|
|
||||||
{description && <CardDescription>{description}</CardDescription>}
|
|
||||||
</div>
|
|
||||||
{canEdit && !isEditing && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 w-8 p-0"
|
|
||||||
onClick={() => setIsEditing(true)}
|
|
||||||
>
|
|
||||||
<Pencil className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{isEditing && editForm ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{editForm}
|
|
||||||
<div className="flex justify-end gap-2 pt-2 border-t">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleCancel}
|
|
||||||
disabled={isSaving}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4 mr-1" />
|
|
||||||
Annuleren
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={isSaving}
|
|
||||||
>
|
|
||||||
{isSaving ? (
|
|
||||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Check className="h-4 w-4 mr-1" />
|
|
||||||
)}
|
|
||||||
Opslaan
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
children
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simple inline edit buttons for list items
|
|
||||||
interface ItemActionsProps {
|
|
||||||
onEdit?: () => void;
|
|
||||||
onDelete?: () => void;
|
|
||||||
isDeleting?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ItemActions({ onEdit, onDelete, isDeleting }: ItemActionsProps) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{onEdit && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 w-7 p-0"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onEdit();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{onDelete && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 w-7 p-0 text-red-500 hover:text-red-700 hover:bg-red-50"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onDelete();
|
|
||||||
}}
|
|
||||||
disabled={isDeleting}
|
|
||||||
>
|
|
||||||
{isDeleting ? (
|
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<X className="h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Progress } from '@/components/ui/progress';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { type Behandeldoel, GOAL_STATUS_LABELS } from '@/lib/types/behandelplan';
|
|
||||||
import { LIFE_DOMAIN_META } from '@/lib/types/leefgebieden';
|
|
||||||
import { Target, Pencil, ChevronDown, ChevronUp } from 'lucide-react';
|
|
||||||
import { BehandeldoelForm } from './behandeldoel-form';
|
|
||||||
|
|
||||||
interface BehandeldoelCardProps {
|
|
||||||
doel: Behandeldoel;
|
|
||||||
isEditing: boolean;
|
|
||||||
onEdit: () => void;
|
|
||||||
onSave: (doel: Behandeldoel) => Promise<void>;
|
|
||||||
onCancel: () => void;
|
|
||||||
onDelete?: () => Promise<void>;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Behandeldoel Card
|
|
||||||
* View mode: Compact card met doel + interventies
|
|
||||||
* Edit mode: Inline form met alle velden
|
|
||||||
*/
|
|
||||||
export function BehandeldoelCard({
|
|
||||||
doel,
|
|
||||||
isEditing,
|
|
||||||
onEdit,
|
|
||||||
onSave,
|
|
||||||
onCancel,
|
|
||||||
onDelete,
|
|
||||||
className,
|
|
||||||
}: BehandeldoelCardProps) {
|
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
|
||||||
|
|
||||||
if (isEditing) {
|
|
||||||
return (
|
|
||||||
<BehandeldoelForm
|
|
||||||
doel={doel}
|
|
||||||
onSave={onSave}
|
|
||||||
onCancel={onCancel}
|
|
||||||
onDelete={onDelete}
|
|
||||||
className={className}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const meta = LIFE_DOMAIN_META[doel.lifeDomain];
|
|
||||||
const statusInfo = GOAL_STATUS_LABELS[doel.status];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
className={cn(
|
|
||||||
'transition-all hover:border-indigo-300 hover:shadow-sm',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<CardHeader className="p-4 pb-2">
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
|
||||||
<Target className="h-4 w-4 text-indigo-600 shrink-0" />
|
|
||||||
<h3 className="font-medium text-slate-900 truncate">{doel.title}</h3>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="text-xs border-0"
|
|
||||||
style={{ backgroundColor: meta.color, color: 'white' }}
|
|
||||||
>
|
|
||||||
{meta.shortLabel}
|
|
||||||
</Badge>
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="text-xs"
|
|
||||||
style={{ borderColor: statusInfo.color, color: statusInfo.color }}
|
|
||||||
>
|
|
||||||
{statusInfo.label}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="p-4 pt-0 space-y-3">
|
|
||||||
{/* Client version (B1 tekst) - altijd zichtbaar */}
|
|
||||||
<div className="bg-blue-50 border border-blue-100 rounded-md p-2.5">
|
|
||||||
<p className="text-sm text-blue-800 italic">
|
|
||||||
“{doel.clientVersion}”
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Interventies */}
|
|
||||||
{doel.interventies.length > 0 && (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<span className="text-xs font-medium text-slate-500 uppercase tracking-wide">
|
|
||||||
Aanpak
|
|
||||||
</span>
|
|
||||||
<ul className="space-y-1">
|
|
||||||
{doel.interventies.map((int) => (
|
|
||||||
<li key={int.id} className="flex items-start gap-2 text-sm">
|
|
||||||
<span className="text-slate-400">•</span>
|
|
||||||
<span>
|
|
||||||
<span className="font-medium text-slate-700">{int.name}</span>
|
|
||||||
{int.description && (
|
|
||||||
<span className="text-slate-500"> - {int.description}</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Progress & timeline */}
|
|
||||||
<div className="flex items-center justify-between gap-4 pt-2">
|
|
||||||
<div className="flex-1 space-y-1">
|
|
||||||
<div className="flex items-center justify-between text-xs text-slate-500">
|
|
||||||
<span>Week {doel.startWeek}-{doel.endWeek}</span>
|
|
||||||
<span>{doel.progress}%</span>
|
|
||||||
</div>
|
|
||||||
<Progress value={doel.progress} className="h-2" />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
|
||||||
className="text-slate-500 h-8 w-8 p-0"
|
|
||||||
>
|
|
||||||
{isExpanded ? (
|
|
||||||
<ChevronUp className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={onEdit}
|
|
||||||
className="text-slate-500 h-8 w-8 p-0 hover:text-indigo-600"
|
|
||||||
>
|
|
||||||
<Pencil className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Expanded details (optional) */}
|
|
||||||
{isExpanded && (
|
|
||||||
<div className="pt-2 border-t border-slate-100 text-xs text-slate-500 space-y-1">
|
|
||||||
<p>Leefgebied: {meta.label}</p>
|
|
||||||
<p>Status: {statusInfo.label}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,357 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { Slider } from '@/components/ui/slider';
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import {
|
|
||||||
type Behandeldoel,
|
|
||||||
type EmbeddedInterventie,
|
|
||||||
type GoalStatus,
|
|
||||||
GOAL_STATUSES,
|
|
||||||
GOAL_STATUS_LABELS,
|
|
||||||
createEmptyEmbeddedInterventie,
|
|
||||||
} from '@/lib/types/behandelplan';
|
|
||||||
import { type LifeDomain, LIFE_DOMAINS, LIFE_DOMAIN_META } from '@/lib/types/leefgebieden';
|
|
||||||
import { Plus, X, Sparkles, Trash2, Save } from 'lucide-react';
|
|
||||||
|
|
||||||
interface BehandeldoelFormProps {
|
|
||||||
doel: Behandeldoel;
|
|
||||||
onSave: (doel: Behandeldoel) => Promise<void>;
|
|
||||||
onCancel: () => void;
|
|
||||||
onDelete?: () => Promise<void>;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Inline edit form voor Behandeldoel
|
|
||||||
* Alle velden in één uitklapbare card
|
|
||||||
*/
|
|
||||||
export function BehandeldoelForm({
|
|
||||||
doel,
|
|
||||||
onSave,
|
|
||||||
onCancel,
|
|
||||||
onDelete,
|
|
||||||
className,
|
|
||||||
}: BehandeldoelFormProps) {
|
|
||||||
const [formData, setFormData] = useState<Behandeldoel>(doel);
|
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
setIsSaving(true);
|
|
||||||
try {
|
|
||||||
await onSave(formData);
|
|
||||||
} finally {
|
|
||||||
setIsSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async () => {
|
|
||||||
if (!onDelete) return;
|
|
||||||
if (!confirm('Weet je zeker dat je dit behandeldoel wilt verwijderen?')) return;
|
|
||||||
setIsDeleting(true);
|
|
||||||
try {
|
|
||||||
await onDelete();
|
|
||||||
} finally {
|
|
||||||
setIsDeleting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateField = <K extends keyof Behandeldoel>(
|
|
||||||
field: K,
|
|
||||||
value: Behandeldoel[K]
|
|
||||||
) => {
|
|
||||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const addInterventie = () => {
|
|
||||||
setFormData((prev) => ({
|
|
||||||
...prev,
|
|
||||||
interventies: [...prev.interventies, createEmptyEmbeddedInterventie()],
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateInterventie = (
|
|
||||||
index: number,
|
|
||||||
field: keyof EmbeddedInterventie,
|
|
||||||
value: string
|
|
||||||
) => {
|
|
||||||
setFormData((prev) => ({
|
|
||||||
...prev,
|
|
||||||
interventies: prev.interventies.map((int, i) =>
|
|
||||||
i === index ? { ...int, [field]: value } : int
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeInterventie = (index: number) => {
|
|
||||||
setFormData((prev) => ({
|
|
||||||
...prev,
|
|
||||||
interventies: prev.interventies.filter((_, i) => i !== index),
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const isValid =
|
|
||||||
formData.title.trim().length >= 5 &&
|
|
||||||
formData.clientVersion.trim().length >= 5;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className={cn('border-indigo-300 shadow-md', className)}>
|
|
||||||
<CardHeader className="p-4 pb-2 border-b bg-indigo-50/50">
|
|
||||||
<CardTitle className="text-base font-medium text-indigo-900">
|
|
||||||
Behandeldoel bewerken
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="p-4 space-y-4">
|
|
||||||
{/* Doel titel */}
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="title" className="text-sm font-medium">
|
|
||||||
Doel <span className="text-red-500">*</span>
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="title"
|
|
||||||
value={formData.title}
|
|
||||||
onChange={(e) => updateField('title', e.target.value)}
|
|
||||||
placeholder="Bijv. Weer 4 dagen per week stabiel kunnen werken"
|
|
||||||
className="text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Client versie (B1) */}
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Label htmlFor="clientVersion" className="text-sm font-medium">
|
|
||||||
Cliënt-versie (B1) <span className="text-red-500">*</span>
|
|
||||||
</Label>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 text-xs text-indigo-600 hover:text-indigo-700"
|
|
||||||
disabled // TODO: Implementeer AI generatie
|
|
||||||
>
|
|
||||||
<Sparkles className="h-3 w-3 mr-1" />
|
|
||||||
Genereer met AI
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<Textarea
|
|
||||||
id="clientVersion"
|
|
||||||
value={formData.clientVersion}
|
|
||||||
onChange={(e) => updateField('clientVersion', e.target.value)}
|
|
||||||
placeholder="Bijv. Ik kan weer 4 dagen werken zonder veel stress"
|
|
||||||
className="text-sm min-h-[60px] resize-none"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-slate-500">
|
|
||||||
Formuleer in eenvoudige taal (B1-niveau) zodat de cliënt het begrijpt.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Leefgebied & Periode - inline */}
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-sm font-medium">Leefgebied</Label>
|
|
||||||
<Select
|
|
||||||
value={formData.lifeDomain}
|
|
||||||
onValueChange={(v) => updateField('lifeDomain', v as LifeDomain)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="text-sm">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{LIFE_DOMAINS.map((domain) => {
|
|
||||||
const meta = LIFE_DOMAIN_META[domain];
|
|
||||||
return (
|
|
||||||
<SelectItem key={domain} value={domain}>
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<span>{meta.emoji}</span>
|
|
||||||
<span>{meta.shortLabel}</span>
|
|
||||||
</span>
|
|
||||||
</SelectItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-sm font-medium">Periode</Label>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
max={52}
|
|
||||||
value={formData.startWeek}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateField('startWeek', parseInt(e.target.value) || 1)
|
|
||||||
}
|
|
||||||
className="w-16 text-sm text-center"
|
|
||||||
/>
|
|
||||||
<span className="text-slate-500 text-sm">t/m</span>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
max={52}
|
|
||||||
value={formData.endWeek}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateField('endWeek', parseInt(e.target.value) || 8)
|
|
||||||
}
|
|
||||||
className="w-16 text-sm text-center"
|
|
||||||
/>
|
|
||||||
<span className="text-slate-500 text-sm">weken</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Interventies */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Label className="text-sm font-medium">Aanpak (interventies)</Label>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={addInterventie}
|
|
||||||
className="h-7 text-xs text-indigo-600 hover:text-indigo-700"
|
|
||||||
>
|
|
||||||
<Plus className="h-3 w-3 mr-1" />
|
|
||||||
Toevoegen
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
{formData.interventies.length === 0 ? (
|
|
||||||
<p className="text-sm text-slate-500 italic py-2">
|
|
||||||
Nog geen interventies toegevoegd
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
formData.interventies.map((int, index) => (
|
|
||||||
<div
|
|
||||||
key={int.id}
|
|
||||||
className="flex items-start gap-2 p-2 bg-slate-50 rounded-md"
|
|
||||||
>
|
|
||||||
<div className="flex-1 grid grid-cols-3 gap-2">
|
|
||||||
<Input
|
|
||||||
value={int.name}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateInterventie(index, 'name', e.target.value)
|
|
||||||
}
|
|
||||||
placeholder="CGT"
|
|
||||||
className="text-sm"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
value={int.description}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateInterventie(index, 'description', e.target.value)
|
|
||||||
}
|
|
||||||
placeholder="Korte beschrijving"
|
|
||||||
className="text-sm col-span-2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => removeInterventie(index)}
|
|
||||||
className="h-8 w-8 p-0 text-slate-400 hover:text-red-500"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status & Voortgang */}
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-sm font-medium">Status</Label>
|
|
||||||
<Select
|
|
||||||
value={formData.status}
|
|
||||||
onValueChange={(v) => updateField('status', v as GoalStatus)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="text-sm">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{GOAL_STATUSES.map((status) => {
|
|
||||||
const info = GOAL_STATUS_LABELS[status];
|
|
||||||
return (
|
|
||||||
<SelectItem key={status} value={status}>
|
|
||||||
{info.label}
|
|
||||||
</SelectItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-sm font-medium">
|
|
||||||
Voortgang: {formData.progress}%
|
|
||||||
</Label>
|
|
||||||
<Slider
|
|
||||||
value={[formData.progress]}
|
|
||||||
onValueChange={([v]) => updateField('progress', v)}
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
step={5}
|
|
||||||
className="mt-2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex items-center justify-between pt-2 border-t">
|
|
||||||
{onDelete && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleDelete}
|
|
||||||
disabled={isDeleting}
|
|
||||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4 mr-1" />
|
|
||||||
{isDeleting ? 'Verwijderen...' : 'Verwijderen'}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center gap-2 ml-auto">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={onCancel}
|
|
||||||
disabled={isSaving}
|
|
||||||
>
|
|
||||||
Annuleren
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={!isValid || isSaving}
|
|
||||||
className="bg-indigo-600 hover:bg-indigo-700"
|
|
||||||
>
|
|
||||||
<Save className="h-4 w-4 mr-1" />
|
|
||||||
{isSaving ? 'Opslaan...' : 'Opslaan'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,290 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import {
|
|
||||||
type Behandeldoel,
|
|
||||||
type Behandelstructuur,
|
|
||||||
type Evaluatiemoment,
|
|
||||||
type Veiligheidsplan,
|
|
||||||
type SmartGoal,
|
|
||||||
type Intervention,
|
|
||||||
type FhirCarePlanStatus,
|
|
||||||
FHIR_STATUS_LABELS,
|
|
||||||
transformToFlat,
|
|
||||||
createEmptyBehandeldoel,
|
|
||||||
calculateBehandeldoelenProgress,
|
|
||||||
} from '@/lib/types/behandelplan';
|
|
||||||
import { type LifeDomainScore } from '@/lib/types/leefgebieden';
|
|
||||||
import { ContextHeader } from './context-header';
|
|
||||||
import { BehandeldoelCard } from './behandeldoel-card';
|
|
||||||
import { PlanningSection } from './planning-section';
|
|
||||||
import { Plus, Sparkles, FileText, CheckCircle2 } from 'lucide-react';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Progress } from '@/components/ui/progress';
|
|
||||||
|
|
||||||
interface Condition {
|
|
||||||
id: string;
|
|
||||||
category: string;
|
|
||||||
code_display: string;
|
|
||||||
severity_code: string | null;
|
|
||||||
severity_display: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CarePlan {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
status: FhirCarePlanStatus;
|
|
||||||
version: number | null;
|
|
||||||
goals: SmartGoal[] | null;
|
|
||||||
activities: Intervention[] | null;
|
|
||||||
behandelstructuur: Behandelstructuur | null;
|
|
||||||
sessie_planning: unknown[] | null;
|
|
||||||
evaluatiemomenten: Evaluatiemoment[] | null;
|
|
||||||
veiligheidsplan: Veiligheidsplan | null;
|
|
||||||
created_at: string | null;
|
|
||||||
published_at: string | null;
|
|
||||||
period_start: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BehandelplanFlatProps {
|
|
||||||
patientId: string;
|
|
||||||
carePlan: CarePlan | null;
|
|
||||||
condition: Condition | null;
|
|
||||||
hulpvraag: string | null;
|
|
||||||
lifeDomainScores: LifeDomainScore[] | null;
|
|
||||||
// Callbacks
|
|
||||||
onGenerate?: () => Promise<void>;
|
|
||||||
onCreateManual?: () => Promise<void>;
|
|
||||||
onStatusChange?: (status: FhirCarePlanStatus) => Promise<void>;
|
|
||||||
onSaveBehandeldoel?: (doel: Behandeldoel) => Promise<void>;
|
|
||||||
onDeleteBehandeldoel?: (doelId: string) => Promise<void>;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* BehandelplanFlat - Hoofdcomponent voor plat behandelplan
|
|
||||||
*
|
|
||||||
* 3 blokken:
|
|
||||||
* 1. Context Header (read-only): Diagnose, hulpvraag, leefgebieden
|
|
||||||
* 2. Behandeldoelen (editable): Cards met inline interventies
|
|
||||||
* 3. Planning & Evaluatie (collapsed): Evaluaties, sessies, veiligheidsplan
|
|
||||||
*/
|
|
||||||
export function BehandelplanFlat({
|
|
||||||
patientId,
|
|
||||||
carePlan,
|
|
||||||
condition,
|
|
||||||
hulpvraag,
|
|
||||||
lifeDomainScores,
|
|
||||||
onGenerate,
|
|
||||||
onCreateManual,
|
|
||||||
onStatusChange,
|
|
||||||
onSaveBehandeldoel,
|
|
||||||
onDeleteBehandeldoel,
|
|
||||||
className,
|
|
||||||
}: BehandelplanFlatProps) {
|
|
||||||
const [editingDoelId, setEditingDoelId] = useState<string | null>(null);
|
|
||||||
const [isGenerating, setIsGenerating] = useState(false);
|
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
|
||||||
|
|
||||||
// Transform old structure to flat
|
|
||||||
const behandeldoelen: Behandeldoel[] = carePlan?.goals && carePlan?.activities
|
|
||||||
? transformToFlat(carePlan.goals, carePlan.activities)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const totalProgress = calculateBehandeldoelenProgress(behandeldoelen);
|
|
||||||
const statusInfo = carePlan?.status ? FHIR_STATUS_LABELS[carePlan.status] : null;
|
|
||||||
|
|
||||||
// Handlers
|
|
||||||
const handleGenerate = async () => {
|
|
||||||
if (!onGenerate) return;
|
|
||||||
setIsGenerating(true);
|
|
||||||
try {
|
|
||||||
await onGenerate();
|
|
||||||
} finally {
|
|
||||||
setIsGenerating(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateManual = async () => {
|
|
||||||
if (!onCreateManual) return;
|
|
||||||
setIsCreating(true);
|
|
||||||
try {
|
|
||||||
await onCreateManual();
|
|
||||||
} finally {
|
|
||||||
setIsCreating(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveDoel = async (doel: Behandeldoel) => {
|
|
||||||
if (!onSaveBehandeldoel) return;
|
|
||||||
await onSaveBehandeldoel(doel);
|
|
||||||
setEditingDoelId(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteDoel = async (doelId: string) => {
|
|
||||||
if (!onDeleteBehandeldoel) return;
|
|
||||||
await onDeleteBehandeldoel(doelId);
|
|
||||||
setEditingDoelId(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddDoel = () => {
|
|
||||||
const newDoel = createEmptyBehandeldoel();
|
|
||||||
// Start editing immediately
|
|
||||||
setEditingDoelId(newDoel.id);
|
|
||||||
// We need to save this empty doel first, then edit
|
|
||||||
// For now, we'll handle this in the parent component
|
|
||||||
};
|
|
||||||
|
|
||||||
// No plan yet - show creation options
|
|
||||||
if (!carePlan) {
|
|
||||||
return (
|
|
||||||
<div className={cn('space-y-4', className)}>
|
|
||||||
{/* Context header */}
|
|
||||||
<ContextHeader
|
|
||||||
condition={condition}
|
|
||||||
hulpvraag={hulpvraag}
|
|
||||||
lifeDomainScores={lifeDomainScores}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Creation options */}
|
|
||||||
<Card className="border-dashed border-2 border-slate-300">
|
|
||||||
<CardContent className="p-6 text-center space-y-4">
|
|
||||||
<div className="mx-auto w-12 h-12 rounded-full bg-indigo-100 flex items-center justify-center">
|
|
||||||
<FileText className="h-6 w-6 text-indigo-600" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-medium text-slate-900">
|
|
||||||
Nog geen behandelplan
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-slate-500 mt-1">
|
|
||||||
Maak een nieuw behandelplan aan
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-center gap-3">
|
|
||||||
<Button
|
|
||||||
onClick={handleGenerate}
|
|
||||||
disabled={isGenerating}
|
|
||||||
className="bg-indigo-600 hover:bg-indigo-700"
|
|
||||||
>
|
|
||||||
<Sparkles className="h-4 w-4 mr-2" />
|
|
||||||
{isGenerating ? 'Genereren...' : 'Genereer met AI'}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleCreateManual}
|
|
||||||
disabled={isCreating}
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
|
||||||
{isCreating ? 'Aanmaken...' : 'Handmatig aanmaken'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn('space-y-4', className)}>
|
|
||||||
{/* Plan header with status */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<h2 className="text-lg font-semibold text-slate-900">
|
|
||||||
{carePlan.title || 'Behandelplan'}
|
|
||||||
</h2>
|
|
||||||
{statusInfo && (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
style={{ borderColor: statusInfo.color, color: statusInfo.color }}
|
|
||||||
>
|
|
||||||
{statusInfo.label}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{carePlan.version && (
|
|
||||||
<span className="text-sm text-slate-500">v{carePlan.version}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{/* Overall progress */}
|
|
||||||
<div className="flex items-center gap-2 text-sm text-slate-600">
|
|
||||||
<span>Voortgang:</span>
|
|
||||||
<div className="w-24">
|
|
||||||
<Progress value={totalProgress} className="h-2" />
|
|
||||||
</div>
|
|
||||||
<span className="font-medium">{totalProgress}%</span>
|
|
||||||
</div>
|
|
||||||
{/* Status actions */}
|
|
||||||
{carePlan.status === 'draft' && onStatusChange && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onStatusChange('active')}
|
|
||||||
className="bg-green-600 hover:bg-green-700"
|
|
||||||
>
|
|
||||||
<CheckCircle2 className="h-4 w-4 mr-1" />
|
|
||||||
Activeren
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Block 1: Context Header */}
|
|
||||||
<ContextHeader
|
|
||||||
condition={condition}
|
|
||||||
hulpvraag={hulpvraag}
|
|
||||||
lifeDomainScores={lifeDomainScores}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Block 2: Behandeldoelen */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h3 className="text-sm font-medium text-slate-700 uppercase tracking-wide">
|
|
||||||
Behandeldoelen ({behandeldoelen.length})
|
|
||||||
</h3>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleAddDoel}
|
|
||||||
className="text-indigo-600 hover:text-indigo-700"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4 mr-1" />
|
|
||||||
Nieuw doel
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{behandeldoelen.length === 0 ? (
|
|
||||||
<Card className="border-dashed">
|
|
||||||
<CardContent className="p-6 text-center">
|
|
||||||
<p className="text-sm text-slate-500">
|
|
||||||
Nog geen behandeldoelen. Klik op "Nieuw doel" om te beginnen.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{behandeldoelen.map((doel) => (
|
|
||||||
<BehandeldoelCard
|
|
||||||
key={doel.id}
|
|
||||||
doel={doel}
|
|
||||||
isEditing={editingDoelId === doel.id}
|
|
||||||
onEdit={() => setEditingDoelId(doel.id)}
|
|
||||||
onSave={handleSaveDoel}
|
|
||||||
onCancel={() => setEditingDoelId(null)}
|
|
||||||
onDelete={() => handleDeleteDoel(doel.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Block 3: Planning & Evaluatie */}
|
|
||||||
<PlanningSection
|
|
||||||
behandelstructuur={carePlan.behandelstructuur}
|
|
||||||
evaluatiemomenten={carePlan.evaluatiemomenten}
|
|
||||||
veiligheidsplan={carePlan.veiligheidsplan}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { type LifeDomain, type LifeDomainScore, LIFE_DOMAIN_META } from '@/lib/types/leefgebieden';
|
|
||||||
import { Stethoscope, MessageSquareQuote } from 'lucide-react';
|
|
||||||
|
|
||||||
interface Condition {
|
|
||||||
id: string;
|
|
||||||
category: string;
|
|
||||||
code_display: string;
|
|
||||||
severity_code: string | null;
|
|
||||||
severity_display: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ContextHeaderProps {
|
|
||||||
condition: Condition | null;
|
|
||||||
hulpvraag: string | null;
|
|
||||||
lifeDomainScores: LifeDomainScore[] | null;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Blok 1: Context Header
|
|
||||||
* Read-only samenvatting van diagnose, hulpvraag en leefgebieden
|
|
||||||
*/
|
|
||||||
export function ContextHeader({
|
|
||||||
condition,
|
|
||||||
hulpvraag,
|
|
||||||
lifeDomainScores,
|
|
||||||
className,
|
|
||||||
}: ContextHeaderProps) {
|
|
||||||
// Filter op leefgebieden met hoge prioriteit of lage scores
|
|
||||||
const priorityDomains = lifeDomainScores?.filter(
|
|
||||||
(s) => s.priority === 'hoog' || s.baseline <= 2
|
|
||||||
) || [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className={cn('bg-slate-50 border-slate-200', className)}>
|
|
||||||
<CardContent className="p-4 space-y-3">
|
|
||||||
{/* Diagnose */}
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<Stethoscope className="h-4 w-4 text-slate-500 mt-0.5 shrink-0" />
|
|
||||||
<div className="min-w-0">
|
|
||||||
<span className="text-xs font-medium text-slate-500 uppercase tracking-wide">
|
|
||||||
Diagnose
|
|
||||||
</span>
|
|
||||||
{condition ? (
|
|
||||||
<p className="text-sm font-medium text-slate-900">
|
|
||||||
{condition.code_display}
|
|
||||||
{condition.severity_display && (
|
|
||||||
<span className="text-slate-500 font-normal ml-1">
|
|
||||||
({condition.severity_display})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-slate-500 italic">Geen diagnose vastgesteld</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Hulpvraag */}
|
|
||||||
{hulpvraag && (
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<MessageSquareQuote className="h-4 w-4 text-slate-500 mt-0.5 shrink-0" />
|
|
||||||
<div className="min-w-0">
|
|
||||||
<span className="text-xs font-medium text-slate-500 uppercase tracking-wide">
|
|
||||||
Hulpvraag
|
|
||||||
</span>
|
|
||||||
<p className="text-sm text-slate-700 italic">“{hulpvraag}”</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Leefgebieden bars */}
|
|
||||||
{priorityDomains.length > 0 && (
|
|
||||||
<div className="pt-2 border-t border-slate-200">
|
|
||||||
<span className="text-xs font-medium text-slate-500 uppercase tracking-wide block mb-2">
|
|
||||||
Prioritaire leefgebieden
|
|
||||||
</span>
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2">
|
|
||||||
{priorityDomains.map((score) => (
|
|
||||||
<LifeDomainBar key={score.domain} score={score} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LifeDomainBarProps {
|
|
||||||
score: LifeDomainScore;
|
|
||||||
}
|
|
||||||
|
|
||||||
function LifeDomainBar({ score }: LifeDomainBarProps) {
|
|
||||||
const meta = LIFE_DOMAIN_META[score.domain];
|
|
||||||
const progressPercent = (score.baseline / 5) * 100;
|
|
||||||
const targetPercent = (score.target / 5) * 100;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex items-center justify-between text-xs">
|
|
||||||
<span className="font-medium text-slate-700">
|
|
||||||
{meta.emoji} {meta.shortLabel}
|
|
||||||
</span>
|
|
||||||
<span className="text-slate-500">
|
|
||||||
{score.baseline} → {score.target}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-2 bg-slate-200 rounded-full relative overflow-hidden">
|
|
||||||
{/* Target indicator */}
|
|
||||||
<div
|
|
||||||
className="absolute h-full w-0.5 bg-slate-400 z-10"
|
|
||||||
style={{ left: `${targetPercent}%` }}
|
|
||||||
/>
|
|
||||||
{/* Current progress */}
|
|
||||||
<div
|
|
||||||
className="h-full rounded-full transition-all"
|
|
||||||
style={{
|
|
||||||
width: `${progressPercent}%`,
|
|
||||||
backgroundColor: meta.color,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
export { BehandelplanFlat } from './behandelplan-flat';
|
|
||||||
export { ContextHeader } from './context-header';
|
|
||||||
export { BehandeldoelCard } from './behandeldoel-card';
|
|
||||||
export { BehandeldoelForm } from './behandeldoel-form';
|
|
||||||
export { PlanningSection } from './planning-section';
|
|
||||||
@@ -1,251 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import {
|
|
||||||
type Behandelstructuur,
|
|
||||||
type Evaluatiemoment,
|
|
||||||
type Veiligheidsplan,
|
|
||||||
EVALUATION_STATUSES,
|
|
||||||
} from '@/lib/types/behandelplan';
|
|
||||||
import {
|
|
||||||
ChevronDown,
|
|
||||||
ChevronRight,
|
|
||||||
Calendar,
|
|
||||||
Clock,
|
|
||||||
Shield,
|
|
||||||
AlertTriangle,
|
|
||||||
Phone,
|
|
||||||
CheckCircle2,
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
interface PlanningSectionProps {
|
|
||||||
behandelstructuur: Behandelstructuur | null;
|
|
||||||
evaluatiemomenten: Evaluatiemoment[] | null;
|
|
||||||
veiligheidsplan: Veiligheidsplan | null;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Blok 3: Planning & Evaluatie
|
|
||||||
* Collapsed by default, bevat:
|
|
||||||
* - Evaluatiemomenten
|
|
||||||
* - Behandelstructuur
|
|
||||||
* - Veiligheidsplan (indien aanwezig)
|
|
||||||
*/
|
|
||||||
export function PlanningSection({
|
|
||||||
behandelstructuur,
|
|
||||||
evaluatiemomenten,
|
|
||||||
veiligheidsplan,
|
|
||||||
className,
|
|
||||||
}: PlanningSectionProps) {
|
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
|
||||||
|
|
||||||
const evaluatiesCount = evaluatiemomenten?.length || 0;
|
|
||||||
const hasVeiligheidsplan = !!veiligheidsplan;
|
|
||||||
|
|
||||||
// Count pending evaluations
|
|
||||||
const pendingEvaluaties =
|
|
||||||
evaluatiemomenten?.filter((e) => e.status === 'gepland').length || 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className={cn('', className)}>
|
|
||||||
{/* Collapsed header */}
|
|
||||||
<CardHeader
|
|
||||||
className="p-3 cursor-pointer hover:bg-slate-50 transition-colors"
|
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{isExpanded ? (
|
|
||||||
<ChevronDown className="h-4 w-4 text-slate-500" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className="h-4 w-4 text-slate-500" />
|
|
||||||
)}
|
|
||||||
<Calendar className="h-4 w-4 text-slate-500" />
|
|
||||||
<span className="font-medium text-slate-700">
|
|
||||||
Planning & Evaluatie
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{pendingEvaluaties > 0 && (
|
|
||||||
<Badge variant="secondary" className="text-xs">
|
|
||||||
{pendingEvaluaties} gepland
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{hasVeiligheidsplan && (
|
|
||||||
<Badge variant="outline" className="text-xs text-orange-600 border-orange-300">
|
|
||||||
<Shield className="h-3 w-3 mr-1" />
|
|
||||||
Veiligheidsplan
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
{/* Expanded content */}
|
|
||||||
{isExpanded && (
|
|
||||||
<CardContent className="p-4 pt-0 space-y-4 border-t">
|
|
||||||
{/* Behandelstructuur */}
|
|
||||||
{behandelstructuur && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h4 className="text-xs font-medium text-slate-500 uppercase tracking-wide">
|
|
||||||
Behandelstructuur
|
|
||||||
</h4>
|
|
||||||
<div className="flex flex-wrap gap-3 text-sm">
|
|
||||||
<div className="flex items-center gap-1.5 text-slate-700">
|
|
||||||
<Clock className="h-4 w-4 text-slate-400" />
|
|
||||||
<span>{behandelstructuur.duur}</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-slate-300">•</span>
|
|
||||||
<span className="text-slate-700">{behandelstructuur.frequentie}</span>
|
|
||||||
<span className="text-slate-300">•</span>
|
|
||||||
<span className="text-slate-700">
|
|
||||||
{behandelstructuur.aantalSessies} sessies
|
|
||||||
</span>
|
|
||||||
<span className="text-slate-300">•</span>
|
|
||||||
<span className="text-slate-700">{behandelstructuur.vorm}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Evaluatiemomenten */}
|
|
||||||
{evaluatiemomenten && evaluatiemomenten.length > 0 && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h4 className="text-xs font-medium text-slate-500 uppercase tracking-wide">
|
|
||||||
Evaluatiemomenten
|
|
||||||
</h4>
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
|
||||||
{evaluatiemomenten.map((eval_) => (
|
|
||||||
<EvaluatieItem key={eval_.id} evaluatie={eval_} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Veiligheidsplan */}
|
|
||||||
{veiligheidsplan && (
|
|
||||||
<VeiligheidsplanSection veiligheidsplan={veiligheidsplan} />
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EvaluatieItemProps {
|
|
||||||
evaluatie: Evaluatiemoment;
|
|
||||||
}
|
|
||||||
|
|
||||||
function EvaluatieItem({ evaluatie }: EvaluatieItemProps) {
|
|
||||||
const isCompleted = evaluatie.status === 'afgerond';
|
|
||||||
const typeLabel =
|
|
||||||
evaluatie.type === 'tussentijds'
|
|
||||||
? 'Tussentijds'
|
|
||||||
: evaluatie.type === 'eind'
|
|
||||||
? 'Eind'
|
|
||||||
: 'Crisis';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-2 p-2 rounded-md text-sm',
|
|
||||||
isCompleted ? 'bg-green-50' : 'bg-slate-50'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isCompleted ? (
|
|
||||||
<CheckCircle2 className="h-4 w-4 text-green-600 shrink-0" />
|
|
||||||
) : (
|
|
||||||
<div className="h-4 w-4 rounded-full border-2 border-slate-300 shrink-0" />
|
|
||||||
)}
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="font-medium text-slate-700 truncate">
|
|
||||||
Week {evaluatie.weekNumber}: {typeLabel}
|
|
||||||
</p>
|
|
||||||
{evaluatie.plannedDate && (
|
|
||||||
<p className="text-xs text-slate-500">
|
|
||||||
{new Date(evaluatie.plannedDate).toLocaleDateString('nl-NL', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface VeiligheidsplanSectionProps {
|
|
||||||
veiligheidsplan: Veiligheidsplan;
|
|
||||||
}
|
|
||||||
|
|
||||||
function VeiligheidsplanSection({ veiligheidsplan }: VeiligheidsplanSectionProps) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-3 p-3 bg-orange-50 border border-orange-200 rounded-md">
|
|
||||||
<div className="flex items-center gap-2 text-orange-700">
|
|
||||||
<Shield className="h-4 w-4" />
|
|
||||||
<h4 className="font-medium text-sm">Veiligheidsplan</h4>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Waarschuwingssignalen */}
|
|
||||||
{veiligheidsplan.waarschuwingssignalen.length > 0 && (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-xs font-medium text-orange-700 flex items-center gap-1">
|
|
||||||
<AlertTriangle className="h-3 w-3" />
|
|
||||||
Waarschuwingssignalen
|
|
||||||
</p>
|
|
||||||
<ul className="text-sm text-orange-900 space-y-0.5">
|
|
||||||
{veiligheidsplan.waarschuwingssignalen.map((signal, i) => (
|
|
||||||
<li key={i} className="flex items-start gap-1.5">
|
|
||||||
<span className="text-orange-400">•</span>
|
|
||||||
<span>{signal}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Coping strategieën */}
|
|
||||||
{veiligheidsplan.copingStrategieen.length > 0 && (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-xs font-medium text-orange-700">
|
|
||||||
Coping strategieën
|
|
||||||
</p>
|
|
||||||
<ul className="text-sm text-orange-900 space-y-0.5">
|
|
||||||
{veiligheidsplan.copingStrategieen.map((strategy, i) => (
|
|
||||||
<li key={i} className="flex items-start gap-1.5">
|
|
||||||
<span className="text-orange-400">•</span>
|
|
||||||
<span>{strategy}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Contacten */}
|
|
||||||
{veiligheidsplan.contacten.length > 0 && (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-xs font-medium text-orange-700 flex items-center gap-1">
|
|
||||||
<Phone className="h-3 w-3" />
|
|
||||||
Noodcontacten
|
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
{veiligheidsplan.contacten.map((contact, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="text-sm bg-white/50 rounded p-1.5 text-orange-900"
|
|
||||||
>
|
|
||||||
<p className="font-medium">{contact.naam}</p>
|
|
||||||
<p className="text-xs text-orange-700">{contact.rol}</p>
|
|
||||||
<p className="text-xs">{contact.telefoon}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
/**
|
|
||||||
* Behandelplan Components
|
|
||||||
*
|
|
||||||
* Export all behandelplan-related components
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Leefgebieden (Life Domains)
|
|
||||||
export { LeefgebiedenBadge, LeefgebiedenBadgeGroup } from './leefgebieden-badge';
|
|
||||||
export {
|
|
||||||
LeefgebiedenScores,
|
|
||||||
LeefgebiedenScoresCard,
|
|
||||||
LeefgebiedenScoreBar,
|
|
||||||
} from './leefgebieden-scores';
|
|
||||||
export { LeefgebiedenForm, LeefgebiedenQuickForm } from './leefgebieden-form';
|
|
||||||
|
|
||||||
// Behandelplan Views
|
|
||||||
export { BehandelplanView } from './behandelplan-view';
|
|
||||||
export { BehandelplanList } from './behandelplan-list';
|
|
||||||
|
|
||||||
// Editable Components
|
|
||||||
export { EditableSection, ItemActions } from './editable-section';
|
|
||||||
|
|
||||||
// Section Forms
|
|
||||||
export { BehandelstructuurForm } from './sections/behandelstructuur-form';
|
|
||||||
export { GoalForm } from './sections/goal-form';
|
|
||||||
export { InterventionForm } from './sections/intervention-form';
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { type LifeDomain, LIFE_DOMAIN_META } from '@/lib/types/leefgebieden';
|
|
||||||
|
|
||||||
interface LeefgebiedenBadgeProps {
|
|
||||||
domain: LifeDomain;
|
|
||||||
showEmoji?: boolean;
|
|
||||||
size?: 'sm' | 'md' | 'lg';
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Colored badge for a life domain
|
|
||||||
* Uses the domain's specific color from the meta definition
|
|
||||||
*/
|
|
||||||
export function LeefgebiedenBadge({
|
|
||||||
domain,
|
|
||||||
showEmoji = true,
|
|
||||||
size = 'md',
|
|
||||||
className,
|
|
||||||
}: LeefgebiedenBadgeProps) {
|
|
||||||
const meta = LIFE_DOMAIN_META[domain];
|
|
||||||
|
|
||||||
const sizeClasses = {
|
|
||||||
sm: 'text-xs px-1.5 py-0.5',
|
|
||||||
md: 'text-sm px-2 py-0.5',
|
|
||||||
lg: 'text-base px-3 py-1',
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Badge
|
|
||||||
className={cn(
|
|
||||||
'font-medium border-0 text-white',
|
|
||||||
sizeClasses[size],
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
style={{ backgroundColor: meta.color }}
|
|
||||||
>
|
|
||||||
{showEmoji && <span className="mr-1">{meta.emoji}</span>}
|
|
||||||
{meta.shortLabel}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LeefgebiedenBadgeGroupProps {
|
|
||||||
domains: LifeDomain[];
|
|
||||||
showEmoji?: boolean;
|
|
||||||
size?: 'sm' | 'md' | 'lg';
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Group of life domain badges
|
|
||||||
*/
|
|
||||||
export function LeefgebiedenBadgeGroup({
|
|
||||||
domains,
|
|
||||||
showEmoji = true,
|
|
||||||
size = 'sm',
|
|
||||||
className,
|
|
||||||
}: LeefgebiedenBadgeGroupProps) {
|
|
||||||
if (domains.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn('flex flex-wrap gap-1', className)}>
|
|
||||||
{domains.map((domain) => (
|
|
||||||
<LeefgebiedenBadge
|
|
||||||
key={domain}
|
|
||||||
domain={domain}
|
|
||||||
showEmoji={showEmoji}
|
|
||||||
size={size}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,368 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { Slider } from '@/components/ui/slider';
|
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import {
|
|
||||||
type LifeDomainScore,
|
|
||||||
type LifeDomain,
|
|
||||||
type Priority,
|
|
||||||
LIFE_DOMAIN_META,
|
|
||||||
LIFE_DOMAINS,
|
|
||||||
createDefaultLifeDomainScores,
|
|
||||||
getScoreColor,
|
|
||||||
} from '@/lib/types/leefgebieden';
|
|
||||||
|
|
||||||
interface DomainFormRowProps {
|
|
||||||
score: LifeDomainScore;
|
|
||||||
onChange: (score: LifeDomainScore) => void;
|
|
||||||
expanded?: boolean;
|
|
||||||
onToggleExpand?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SCORE_LABELS: Record<number, string> = {
|
|
||||||
1: 'Zeer laag',
|
|
||||||
2: 'Laag',
|
|
||||||
3: 'Gemiddeld',
|
|
||||||
4: 'Goed',
|
|
||||||
5: 'Uitstekend',
|
|
||||||
};
|
|
||||||
|
|
||||||
const PRIORITY_OPTIONS: { value: Priority; label: string; color: string }[] = [
|
|
||||||
{ value: 'laag', label: 'Laag', color: 'bg-gray-200 text-gray-700' },
|
|
||||||
{ value: 'middel', label: 'Middel', color: 'bg-blue-100 text-blue-700' },
|
|
||||||
{ value: 'hoog', label: 'Hoog', color: 'bg-orange-100 text-orange-700' },
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Single domain row in the form
|
|
||||||
*/
|
|
||||||
function DomainFormRow({
|
|
||||||
score,
|
|
||||||
onChange,
|
|
||||||
expanded = false,
|
|
||||||
onToggleExpand,
|
|
||||||
}: DomainFormRowProps) {
|
|
||||||
const meta = LIFE_DOMAIN_META[score.domain];
|
|
||||||
|
|
||||||
const handleBaselineChange = (values: number[]) => {
|
|
||||||
onChange({ ...score, baseline: values[0], current: values[0] });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTargetChange = (values: number[]) => {
|
|
||||||
onChange({ ...score, target: values[0] });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePriorityChange = (priority: Priority) => {
|
|
||||||
onChange({ ...score, priority });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNotesChange = (notes: string) => {
|
|
||||||
onChange({ ...score, notes });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'border rounded-lg p-4 transition-all',
|
|
||||||
expanded ? 'bg-muted/50' : 'hover:bg-muted/30',
|
|
||||||
score.priority === 'hoog' && 'border-orange-300'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Header Row */}
|
|
||||||
<div
|
|
||||||
className="flex items-center justify-between cursor-pointer"
|
|
||||||
onClick={onToggleExpand}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div
|
|
||||||
className="w-10 h-10 rounded-lg flex items-center justify-center text-xl"
|
|
||||||
style={{ backgroundColor: `${meta.color}20` }}
|
|
||||||
>
|
|
||||||
{meta.emoji}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium">{meta.label}</h4>
|
|
||||||
<p className="text-sm text-muted-foreground">{meta.description}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
className="text-lg font-bold"
|
|
||||||
style={{ color: getScoreColor(score.baseline) }}
|
|
||||||
>
|
|
||||||
{score.baseline}
|
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground">→</span>
|
|
||||||
<span className="text-lg font-bold text-foreground">
|
|
||||||
{score.target}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{SCORE_LABELS[score.baseline]}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<svg
|
|
||||||
className={cn(
|
|
||||||
'w-5 h-5 text-muted-foreground transition-transform',
|
|
||||||
expanded && 'rotate-180'
|
|
||||||
)}
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M19 9l-7 7-7-7"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Expanded Content */}
|
|
||||||
{expanded && (
|
|
||||||
<div className="mt-4 space-y-4 pt-4 border-t">
|
|
||||||
{/* Baseline Score Slider */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<Label>Huidige score (baseline)</Label>
|
|
||||||
<span className="text-sm font-medium">{score.baseline}</span>
|
|
||||||
</div>
|
|
||||||
<Slider
|
|
||||||
value={[score.baseline]}
|
|
||||||
onValueChange={handleBaselineChange}
|
|
||||||
min={1}
|
|
||||||
max={5}
|
|
||||||
step={1}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
<div className="flex justify-between text-xs text-muted-foreground">
|
|
||||||
<span>Zeer laag</span>
|
|
||||||
<span>Uitstekend</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Target Score Slider */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<Label>Doelscore</Label>
|
|
||||||
<span className="text-sm font-medium">{score.target}</span>
|
|
||||||
</div>
|
|
||||||
<Slider
|
|
||||||
value={[score.target]}
|
|
||||||
onValueChange={handleTargetChange}
|
|
||||||
min={1}
|
|
||||||
max={5}
|
|
||||||
step={1}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
<div className="flex justify-between text-xs text-muted-foreground">
|
|
||||||
<span>Zeer laag</span>
|
|
||||||
<span>Uitstekend</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Priority Selection */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Prioriteit voor behandeling</Label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{PRIORITY_OPTIONS.map((option) => (
|
|
||||||
<button
|
|
||||||
key={option.value}
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
'px-3 py-1.5 rounded-md text-sm font-medium transition-all',
|
|
||||||
score.priority === option.value
|
|
||||||
? option.color
|
|
||||||
: 'bg-muted text-muted-foreground hover:bg-muted/80'
|
|
||||||
)}
|
|
||||||
onClick={() => handlePriorityChange(option.value)}
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Notes */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Toelichting (optioneel)</Label>
|
|
||||||
<Textarea
|
|
||||||
value={score.notes}
|
|
||||||
onChange={(e) => handleNotesChange(e.target.value)}
|
|
||||||
placeholder={`Opmerkingen over ${meta.shortLabel.toLowerCase()}...`}
|
|
||||||
className="resize-none"
|
|
||||||
rows={2}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LeefgebiedenFormProps {
|
|
||||||
initialScores?: LifeDomainScore[];
|
|
||||||
onSave: (scores: LifeDomainScore[]) => void;
|
|
||||||
onCancel?: () => void;
|
|
||||||
isSaving?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Complete form for entering life domain scores during intake
|
|
||||||
*/
|
|
||||||
export function LeefgebiedenForm({
|
|
||||||
initialScores,
|
|
||||||
onSave,
|
|
||||||
onCancel,
|
|
||||||
isSaving = false,
|
|
||||||
}: LeefgebiedenFormProps) {
|
|
||||||
const [scores, setScores] = useState<LifeDomainScore[]>(
|
|
||||||
initialScores || createDefaultLifeDomainScores()
|
|
||||||
);
|
|
||||||
const [expandedDomain, setExpandedDomain] = useState<LifeDomain | null>(null);
|
|
||||||
|
|
||||||
const handleScoreChange = useCallback((updatedScore: LifeDomainScore) => {
|
|
||||||
setScores((prev) =>
|
|
||||||
prev.map((s) => (s.domain === updatedScore.domain ? updatedScore : s))
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleToggleExpand = useCallback((domain: LifeDomain) => {
|
|
||||||
setExpandedDomain((prev) => (prev === domain ? null : domain));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
onSave(scores);
|
|
||||||
};
|
|
||||||
|
|
||||||
const highPriorityCount = scores.filter((s) => s.priority === 'hoog').length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<span>📊</span>
|
|
||||||
Leefgebieden Assessment
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Beoordeel de 7 leefgebieden van de cliënt. Klik op een gebied om scores
|
|
||||||
en prioriteiten aan te passen.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
{/* Summary Stats */}
|
|
||||||
<div className="flex items-center justify-between text-sm text-muted-foreground mb-4">
|
|
||||||
<span>
|
|
||||||
Klik op een leefgebied om de scores aan te passen
|
|
||||||
</span>
|
|
||||||
{highPriorityCount > 0 && (
|
|
||||||
<span className="text-orange-600 font-medium">
|
|
||||||
{highPriorityCount} hoge prioriteit{highPriorityCount > 1 ? 'en' : ''}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Domain Rows */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
{LIFE_DOMAINS.map((domain) => {
|
|
||||||
const score = scores.find((s) => s.domain === domain)!;
|
|
||||||
return (
|
|
||||||
<DomainFormRow
|
|
||||||
key={domain}
|
|
||||||
score={score}
|
|
||||||
onChange={handleScoreChange}
|
|
||||||
expanded={expandedDomain === domain}
|
|
||||||
onToggleExpand={() => handleToggleExpand(domain)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex justify-end gap-2 pt-4 border-t">
|
|
||||||
{onCancel && (
|
|
||||||
<Button type="button" variant="outline" onClick={onCancel}>
|
|
||||||
Annuleren
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button type="submit" disabled={isSaving}>
|
|
||||||
{isSaving ? 'Opslaan...' : 'Leefgebieden opslaan'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LeefgebiedenQuickFormProps {
|
|
||||||
initialScores?: LifeDomainScore[];
|
|
||||||
onChange: (scores: LifeDomainScore[]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compact version of the form for inline editing
|
|
||||||
*/
|
|
||||||
export function LeefgebiedenQuickForm({
|
|
||||||
initialScores,
|
|
||||||
onChange,
|
|
||||||
}: LeefgebiedenQuickFormProps) {
|
|
||||||
const [scores, setScores] = useState<LifeDomainScore[]>(
|
|
||||||
initialScores || createDefaultLifeDomainScores()
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleScoreChange = useCallback(
|
|
||||||
(domain: LifeDomain, value: number) => {
|
|
||||||
const updated = scores.map((s) =>
|
|
||||||
s.domain === domain ? { ...s, baseline: value, current: value } : s
|
|
||||||
);
|
|
||||||
setScores(updated);
|
|
||||||
onChange(updated);
|
|
||||||
},
|
|
||||||
[scores, onChange]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{LIFE_DOMAINS.map((domain) => {
|
|
||||||
const score = scores.find((s) => s.domain === domain)!;
|
|
||||||
const meta = LIFE_DOMAIN_META[domain];
|
|
||||||
return (
|
|
||||||
<div key={domain} className="flex items-center gap-3">
|
|
||||||
<div className="w-8 text-center text-lg">{meta.emoji}</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex justify-between text-sm mb-1">
|
|
||||||
<span>{meta.shortLabel}</span>
|
|
||||||
<span
|
|
||||||
className="font-medium"
|
|
||||||
style={{ color: getScoreColor(score.baseline) }}
|
|
||||||
>
|
|
||||||
{score.baseline}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Slider
|
|
||||||
value={[score.baseline]}
|
|
||||||
onValueChange={(v) => handleScoreChange(domain, v[0])}
|
|
||||||
min={1}
|
|
||||||
max={5}
|
|
||||||
step={1}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import {
|
|
||||||
type LifeDomainScore,
|
|
||||||
type LifeDomain,
|
|
||||||
LIFE_DOMAIN_META,
|
|
||||||
LIFE_DOMAINS,
|
|
||||||
getScoreColor,
|
|
||||||
getAverageScore,
|
|
||||||
} from '@/lib/types/leefgebieden';
|
|
||||||
import { LeefgebiedenBadge } from './leefgebieden-badge';
|
|
||||||
|
|
||||||
interface LeefgebiedenScoreBarProps {
|
|
||||||
score: LifeDomainScore;
|
|
||||||
showTarget?: boolean;
|
|
||||||
compact?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Single life domain score as a progress bar
|
|
||||||
*/
|
|
||||||
export function LeefgebiedenScoreBar({
|
|
||||||
score,
|
|
||||||
showTarget = true,
|
|
||||||
compact = false,
|
|
||||||
}: LeefgebiedenScoreBarProps) {
|
|
||||||
const meta = LIFE_DOMAIN_META[score.domain];
|
|
||||||
const percentage = (score.current / 5) * 100;
|
|
||||||
const targetPercentage = (score.target / 5) * 100;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn('space-y-1', compact ? 'py-1' : 'py-2')}>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-lg">{meta.emoji}</span>
|
|
||||||
<span className={cn('font-medium', compact ? 'text-sm' : 'text-base')}>
|
|
||||||
{meta.shortLabel}
|
|
||||||
</span>
|
|
||||||
{score.priority === 'hoog' && (
|
|
||||||
<span className="text-xs text-orange-500 font-medium">
|
|
||||||
Prioriteit
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
||||||
<span className="font-medium" style={{ color: getScoreColor(score.current) }}>
|
|
||||||
{score.current}
|
|
||||||
</span>
|
|
||||||
{showTarget && (
|
|
||||||
<>
|
|
||||||
<span>→</span>
|
|
||||||
<span className="font-medium text-foreground">{score.target}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="relative">
|
|
||||||
{/* Custom progress bar with domain-specific color */}
|
|
||||||
<div className="relative h-2 w-full overflow-hidden rounded-full bg-primary/20">
|
|
||||||
<div
|
|
||||||
className="h-full transition-all duration-300"
|
|
||||||
style={{
|
|
||||||
width: `${percentage}%`,
|
|
||||||
backgroundColor: meta.color,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{showTarget && (
|
|
||||||
<div
|
|
||||||
className="absolute top-0 h-2 w-0.5 bg-foreground/50"
|
|
||||||
style={{ left: `${targetPercentage}%` }}
|
|
||||||
title={`Doel: ${score.target}`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LeefgebiedenScoresProps {
|
|
||||||
scores: LifeDomainScore[];
|
|
||||||
showTarget?: boolean;
|
|
||||||
compact?: boolean;
|
|
||||||
showSummary?: boolean;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Display all 7 life domain scores
|
|
||||||
*/
|
|
||||||
export function LeefgebiedenScores({
|
|
||||||
scores,
|
|
||||||
showTarget = true,
|
|
||||||
compact = false,
|
|
||||||
showSummary = true,
|
|
||||||
className,
|
|
||||||
}: LeefgebiedenScoresProps) {
|
|
||||||
// Ensure we have all 7 domains in the correct order
|
|
||||||
const orderedScores = LIFE_DOMAINS.map((domain) => {
|
|
||||||
const found = scores.find((s) => s.domain === domain);
|
|
||||||
return (
|
|
||||||
found || {
|
|
||||||
domain,
|
|
||||||
baseline: 3,
|
|
||||||
current: 3,
|
|
||||||
target: 4,
|
|
||||||
notes: '',
|
|
||||||
priority: 'middel' as const,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const avgCurrent = getAverageScore(orderedScores, 'current');
|
|
||||||
const avgTarget = getAverageScore(orderedScores, 'target');
|
|
||||||
const highPriority = orderedScores.filter((s) => s.priority === 'hoog');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn('space-y-4', className)}>
|
|
||||||
{showSummary && (
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
Gemiddelde score:{' '}
|
|
||||||
<span className="font-medium text-foreground">{avgCurrent}</span>
|
|
||||||
{showTarget && (
|
|
||||||
<span className="text-muted-foreground"> → {avgTarget}</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{highPriority.length > 0 && (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<span className="text-muted-foreground">Prioriteiten:</span>
|
|
||||||
{highPriority.map((s) => (
|
|
||||||
<LeefgebiedenBadge key={s.domain} domain={s.domain} size="sm" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
{orderedScores.map((score) => (
|
|
||||||
<LeefgebiedenScoreBar
|
|
||||||
key={score.domain}
|
|
||||||
score={score}
|
|
||||||
showTarget={showTarget}
|
|
||||||
compact={compact}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LeefgebiedenScoresCardProps {
|
|
||||||
scores: LifeDomainScore[];
|
|
||||||
title?: string;
|
|
||||||
showTarget?: boolean;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Life domain scores in a Card wrapper
|
|
||||||
*/
|
|
||||||
export function LeefgebiedenScoresCard({
|
|
||||||
scores,
|
|
||||||
title = 'Leefgebieden',
|
|
||||||
showTarget = true,
|
|
||||||
className,
|
|
||||||
}: LeefgebiedenScoresCardProps) {
|
|
||||||
return (
|
|
||||||
<Card className={className}>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="text-lg flex items-center gap-2">
|
|
||||||
<span>📊</span>
|
|
||||||
{title}
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<LeefgebiedenScores scores={scores} showTarget={showTarget} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
||||||
import type { Behandelstructuur } from '@/lib/types/behandelplan';
|
|
||||||
|
|
||||||
interface BehandelstructuurFormProps {
|
|
||||||
initialData?: Behandelstructuur | null;
|
|
||||||
onChange: (data: Behandelstructuur) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DUUR_OPTIONS = ['4 weken', '6 weken', '8 weken', '10 weken', '12 weken', '16 weken', '24 weken'];
|
|
||||||
const FREQUENTIE_OPTIONS = ['Wekelijks', 'Tweewekelijks', 'Maandelijks', '2x per week'];
|
|
||||||
const VORM_OPTIONS = ['Individueel', 'Groep', 'Gezin', 'Paar', 'Online', 'Hybride'];
|
|
||||||
|
|
||||||
export function BehandelstructuurForm({ initialData, onChange }: BehandelstructuurFormProps) {
|
|
||||||
const [data, setData] = useState<Behandelstructuur>(
|
|
||||||
initialData || {
|
|
||||||
duur: '8 weken',
|
|
||||||
frequentie: 'Wekelijks',
|
|
||||||
aantalSessies: 8,
|
|
||||||
vorm: 'Individueel',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleChange = (field: keyof Behandelstructuur, value: string | number) => {
|
|
||||||
const updated = { ...data, [field]: value };
|
|
||||||
setData(updated);
|
|
||||||
onChange(updated);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="duur">Duur</Label>
|
|
||||||
<Select value={data.duur} onValueChange={(v) => handleChange('duur', v)}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Selecteer duur" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{DUUR_OPTIONS.map((option) => (
|
|
||||||
<SelectItem key={option} value={option}>
|
|
||||||
{option}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="frequentie">Frequentie</Label>
|
|
||||||
<Select value={data.frequentie} onValueChange={(v) => handleChange('frequentie', v)}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Selecteer frequentie" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{FREQUENTIE_OPTIONS.map((option) => (
|
|
||||||
<SelectItem key={option} value={option}>
|
|
||||||
{option}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="sessies">Aantal sessies</Label>
|
|
||||||
<Input
|
|
||||||
id="sessies"
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
max={52}
|
|
||||||
value={data.aantalSessies}
|
|
||||||
onChange={(e) => handleChange('aantalSessies', parseInt(e.target.value) || 1)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="vorm">Vorm</Label>
|
|
||||||
<Select value={data.vorm} onValueChange={(v) => handleChange('vorm', v)}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Selecteer vorm" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{VORM_OPTIONS.map((option) => (
|
|
||||||
<SelectItem key={option} value={option}>
|
|
||||||
{option}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
|
||||||
import { Slider } from '@/components/ui/slider';
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
||||||
import type { SmartGoal, GoalStatus } from '@/lib/types/behandelplan';
|
|
||||||
import { LIFE_DOMAINS, LIFE_DOMAIN_META, type LifeDomain } from '@/lib/types/leefgebieden';
|
|
||||||
|
|
||||||
interface GoalFormProps {
|
|
||||||
initialData?: SmartGoal | null;
|
|
||||||
onChange: (data: SmartGoal) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PRIORITY_OPTIONS = [
|
|
||||||
{ value: 'hoog', label: 'Hoog' },
|
|
||||||
{ value: 'middel', label: 'Middel' },
|
|
||||||
{ value: 'laag', label: 'Laag' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const STATUS_OPTIONS: { value: GoalStatus; label: string }[] = [
|
|
||||||
{ value: 'niet_gestart', label: 'Niet gestart' },
|
|
||||||
{ value: 'bezig', label: 'Bezig' },
|
|
||||||
{ value: 'gehaald', label: 'Gehaald' },
|
|
||||||
{ value: 'bijgesteld', label: 'Bijgesteld' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function GoalForm({ initialData, onChange }: GoalFormProps) {
|
|
||||||
const [data, setData] = useState<SmartGoal>(
|
|
||||||
initialData || {
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
title: '',
|
|
||||||
description: '',
|
|
||||||
clientVersion: '',
|
|
||||||
lifeDomain: 'dlv',
|
|
||||||
priority: 'middel',
|
|
||||||
measurability: '',
|
|
||||||
timelineWeeks: 8,
|
|
||||||
status: 'niet_gestart',
|
|
||||||
progress: 0,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleChange = <K extends keyof SmartGoal>(field: K, value: SmartGoal[K]) => {
|
|
||||||
const updated = { ...data, [field]: value };
|
|
||||||
setData(updated);
|
|
||||||
onChange(updated);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="title">Titel</Label>
|
|
||||||
<Input
|
|
||||||
id="title"
|
|
||||||
value={data.title}
|
|
||||||
onChange={(e) => handleChange('title', e.target.value)}
|
|
||||||
placeholder="Korte beschrijving van het doel"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="lifeDomain">Leefgebied</Label>
|
|
||||||
<Select
|
|
||||||
value={data.lifeDomain}
|
|
||||||
onValueChange={(v) => handleChange('lifeDomain', v as LifeDomain)}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Selecteer leefgebied" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{LIFE_DOMAINS.map((domain) => (
|
|
||||||
<SelectItem key={domain} value={domain}>
|
|
||||||
{LIFE_DOMAIN_META[domain].emoji} {LIFE_DOMAIN_META[domain].label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="description">SMART Beschrijving</Label>
|
|
||||||
<Textarea
|
|
||||||
id="description"
|
|
||||||
value={data.description}
|
|
||||||
onChange={(e) => handleChange('description', e.target.value)}
|
|
||||||
placeholder="Specifiek, Meetbaar, Acceptabel, Realistisch, Tijdgebonden"
|
|
||||||
rows={2}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="clientVersion">Cliënt versie (B1-taal)</Label>
|
|
||||||
<Textarea
|
|
||||||
id="clientVersion"
|
|
||||||
value={data.clientVersion}
|
|
||||||
onChange={(e) => handleChange('clientVersion', e.target.value)}
|
|
||||||
placeholder="Eenvoudige uitleg voor de cliënt"
|
|
||||||
rows={2}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="priority">Prioriteit</Label>
|
|
||||||
<Select
|
|
||||||
value={data.priority}
|
|
||||||
onValueChange={(v) => handleChange('priority', v as 'hoog' | 'middel' | 'laag')}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{PRIORITY_OPTIONS.map((opt) => (
|
|
||||||
<SelectItem key={opt.value} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="status">Status</Label>
|
|
||||||
<Select
|
|
||||||
value={data.status}
|
|
||||||
onValueChange={(v) => handleChange('status', v as GoalStatus)}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{STATUS_OPTIONS.map((opt) => (
|
|
||||||
<SelectItem key={opt.value} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="timelineWeeks">Tijdlijn (weken)</Label>
|
|
||||||
<Input
|
|
||||||
id="timelineWeeks"
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
max={52}
|
|
||||||
value={data.timelineWeeks}
|
|
||||||
onChange={(e) => handleChange('timelineWeeks', parseInt(e.target.value) || 1)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="measurability">Meetbaarheid</Label>
|
|
||||||
<Input
|
|
||||||
id="measurability"
|
|
||||||
value={data.measurability}
|
|
||||||
onChange={(e) => handleChange('measurability', e.target.value)}
|
|
||||||
placeholder="Hoe meten we vooruitgang?"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<Label>Voortgang</Label>
|
|
||||||
<span className="text-sm text-slate-500">{data.progress}%</span>
|
|
||||||
</div>
|
|
||||||
<Slider
|
|
||||||
value={[data.progress]}
|
|
||||||
onValueChange={(v) => handleChange('progress', v[0])}
|
|
||||||
max={100}
|
|
||||||
step={5}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
|
||||||
import type { Intervention, SmartGoal } from '@/lib/types/behandelplan';
|
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
|
||||||
|
|
||||||
interface InterventionFormProps {
|
|
||||||
initialData?: Intervention | null;
|
|
||||||
goals?: SmartGoal[];
|
|
||||||
onChange: (data: Intervention) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const COMMON_INTERVENTIONS = [
|
|
||||||
'CGT (Cognitieve Gedragstherapie)',
|
|
||||||
'EMDR',
|
|
||||||
'ACT (Acceptance and Commitment Therapy)',
|
|
||||||
'Schematherapie',
|
|
||||||
'Psycho-educatie',
|
|
||||||
'Mindfulness',
|
|
||||||
'Exposure therapie',
|
|
||||||
'Systeemtherapie',
|
|
||||||
];
|
|
||||||
|
|
||||||
export function InterventionForm({ initialData, goals = [], onChange }: InterventionFormProps) {
|
|
||||||
const [data, setData] = useState<Intervention>(
|
|
||||||
initialData || {
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
rationale: '',
|
|
||||||
linkedGoalIds: [],
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleChange = <K extends keyof Intervention>(field: K, value: Intervention[K]) => {
|
|
||||||
const updated = { ...data, [field]: value };
|
|
||||||
setData(updated);
|
|
||||||
onChange(updated);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleGoalLink = (goalId: string) => {
|
|
||||||
const linkedGoalIds = data.linkedGoalIds.includes(goalId)
|
|
||||||
? data.linkedGoalIds.filter((id) => id !== goalId)
|
|
||||||
: [...data.linkedGoalIds, goalId];
|
|
||||||
handleChange('linkedGoalIds', linkedGoalIds);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="name">Naam interventie</Label>
|
|
||||||
<Input
|
|
||||||
id="name"
|
|
||||||
value={data.name}
|
|
||||||
onChange={(e) => handleChange('name', e.target.value)}
|
|
||||||
placeholder="bijv. CGT, EMDR, ACT"
|
|
||||||
list="interventions"
|
|
||||||
/>
|
|
||||||
<datalist id="interventions">
|
|
||||||
{COMMON_INTERVENTIONS.map((name) => (
|
|
||||||
<option key={name} value={name} />
|
|
||||||
))}
|
|
||||||
</datalist>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="description">Beschrijving</Label>
|
|
||||||
<Textarea
|
|
||||||
id="description"
|
|
||||||
value={data.description}
|
|
||||||
onChange={(e) => handleChange('description', e.target.value)}
|
|
||||||
placeholder="Uitleg van de interventie"
|
|
||||||
rows={2}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="rationale">Rationale</Label>
|
|
||||||
<Textarea
|
|
||||||
id="rationale"
|
|
||||||
value={data.rationale}
|
|
||||||
onChange={(e) => handleChange('rationale', e.target.value)}
|
|
||||||
placeholder="Waarom past deze interventie bij deze cliënt?"
|
|
||||||
rows={2}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{goals.length > 0 && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Gekoppelde doelen</Label>
|
|
||||||
<div className="space-y-2 p-3 bg-slate-50 rounded-lg">
|
|
||||||
{goals.map((goal) => (
|
|
||||||
<div key={goal.id} className="flex items-center space-x-2">
|
|
||||||
<Checkbox
|
|
||||||
id={`goal-${goal.id}`}
|
|
||||||
checked={data.linkedGoalIds.includes(goal.id)}
|
|
||||||
onCheckedChange={() => toggleGoalLink(goal.id)}
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
htmlFor={`goal-${goal.id}`}
|
|
||||||
className="text-sm cursor-pointer"
|
|
||||||
>
|
|
||||||
{goal.title || 'Doel zonder titel'}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -41,23 +41,10 @@ interface EncounterSummary {
|
|||||||
status: string;
|
status: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CarePlanSummary {
|
|
||||||
id?: string;
|
|
||||||
title?: string | null;
|
|
||||||
status?: string | null;
|
|
||||||
based_on_intake_id?: string | null;
|
|
||||||
behandelstructuur?: unknown;
|
|
||||||
goals?: unknown;
|
|
||||||
activities?: unknown;
|
|
||||||
evaluatiemomenten?: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PatientDashboardResponse {
|
interface PatientDashboardResponse {
|
||||||
patient: FHIRPatient;
|
patient: FHIRPatient;
|
||||||
intakes: Intake[];
|
intakes: Intake[];
|
||||||
encounters: EncounterSummary[];
|
encounters: EncounterSummary[];
|
||||||
carePlan: CarePlanSummary | null;
|
|
||||||
hulpvraag?: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
@@ -179,11 +166,6 @@ export function PatientDashboardBlock({ prefill }: PatientDashboardBlockProps) {
|
|||||||
return displayEncounters;
|
return displayEncounters;
|
||||||
}, [encounters]);
|
}, [encounters]);
|
||||||
|
|
||||||
const goalsCount = Array.isArray(data?.carePlan?.goals) ? data?.carePlan?.goals.length : 0;
|
|
||||||
const interventionsCount = Array.isArray(data?.carePlan?.activities)
|
|
||||||
? data?.carePlan?.activities.length
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
// E3.S2: Use patientNameFromPrefill for title
|
// E3.S2: Use patientNameFromPrefill for title
|
||||||
const title = patientNameFromPrefill
|
const title = patientNameFromPrefill
|
||||||
? `${config.title} - ${patientNameFromPrefill}`
|
? `${config.title} - ${patientNameFromPrefill}`
|
||||||
@@ -337,36 +319,6 @@ export function PatientDashboardBlock({ prefill }: PatientDashboardBlockProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Behandelplan */}
|
|
||||||
<section className="bg-white rounded-lg border border-slate-200 p-4">
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
|
||||||
<ClipboardList className="h-4 w-4 text-purple-600" />
|
|
||||||
<h3 className="text-sm font-medium text-slate-700">Actief behandelplan</h3>
|
|
||||||
</div>
|
|
||||||
{data.carePlan ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{data.hulpvraag && (
|
|
||||||
<div className="bg-slate-50 rounded-lg p-3 text-sm text-slate-700">
|
|
||||||
<p className="text-xs font-medium text-slate-500 mb-1">Hulpvraag</p>
|
|
||||||
<p className="italic">“{data.hulpvraag}”</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
|
||||||
<div className="bg-teal-50 border border-teal-200 rounded-lg p-3">
|
|
||||||
<p className="text-xs text-teal-700 mb-1">Doelen</p>
|
|
||||||
<p className="text-base font-semibold text-teal-900">{goalsCount}</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-3">
|
|
||||||
<p className="text-xs text-purple-700 mb-1">Interventies</p>
|
|
||||||
<p className="text-base font-semibold text-purple-900">{interventionsCount}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-slate-500">Geen actief behandelplan</p>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-sm text-slate-500">Geen gegevens beschikbaar</div>
|
<div className="text-sm text-slate-500">Geen gegevens beschikbaar</div>
|
||||||
|
|||||||
@@ -1,221 +0,0 @@
|
|||||||
/**
|
|
||||||
* Behandelplan AI Prompts
|
|
||||||
*
|
|
||||||
* System prompt en user prompt templates voor AI-generatie van behandelplannen
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { type LifeDomainScore, LIFE_DOMAIN_META } from '@/lib/types/leefgebieden';
|
|
||||||
import { type Severity, getInterventionsForCategory, getRecommendedSessionCount, getRecommendedFrequency, getRecommendedFormat } from './intervention-mapping';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Context voor behandelplan generatie
|
|
||||||
*/
|
|
||||||
export interface PlanContext {
|
|
||||||
patientId: string;
|
|
||||||
intakeNotes: string;
|
|
||||||
dsmCategory: string;
|
|
||||||
severity: Severity;
|
|
||||||
lifeDomains: LifeDomainScore[];
|
|
||||||
extraInstructions?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* System prompt voor Claude
|
|
||||||
* Instructies voor het genereren van een behandelplan
|
|
||||||
*/
|
|
||||||
export const BEHANDELPLAN_SYSTEM_PROMPT = `Je bent een ervaren GGZ-behandelaar die behandelplannen opstelt voor cliënten in de ambulante GGZ.
|
|
||||||
Je maakt SMART doelen die recovery-gericht en evidence-based zijn.
|
|
||||||
|
|
||||||
INSTRUCTIES:
|
|
||||||
1. Genereer 2-4 SMART doelen gebaseerd op de intake en diagnose
|
|
||||||
2. Focus op leefgebieden met prioriteit "hoog"
|
|
||||||
3. Verdeel doelen over minimaal 2 verschillende leefgebieden
|
|
||||||
4. Maak concrete, meetbare doelen (geen vage termen zoals "beter voelen")
|
|
||||||
5. Genereer voor elk doel een B1-taal versie (cliënt-vriendelijk, simpele woorden)
|
|
||||||
6. Kies evidence-based interventies passend bij de DSM-categorie
|
|
||||||
7. Plan sessies gebaseerd op severity niveau
|
|
||||||
8. Voeg een veiligheidsplan toe ALLEEN bij severity "hoog"
|
|
||||||
|
|
||||||
SMART CRITERIA:
|
|
||||||
- Specifiek: Wat precies wil de cliënt bereiken?
|
|
||||||
- Meetbaar: Hoe meten we vooruitgang? (bijv. "3x per week", "score van 3 naar 4")
|
|
||||||
- Acceptabel: Past bij wensen en mogelijkheden cliënt
|
|
||||||
- Realistisch: Haalbaar binnen de behandelperiode
|
|
||||||
- Tijdgebonden: Binnen hoeveel weken?
|
|
||||||
|
|
||||||
B1-TAAL RICHTLIJNEN (cliënt-versie):
|
|
||||||
- Korte zinnen (max 15 woorden)
|
|
||||||
- Alledaagse woorden (geen jargon)
|
|
||||||
- Actieve zinnen ("Ik ga..." niet "Er zal worden...")
|
|
||||||
- Directe aanspreking ("jij" of "je")
|
|
||||||
|
|
||||||
OUTPUT FORMAT:
|
|
||||||
Retourneer ALLEEN valide JSON volgens dit exacte schema (geen extra tekst):
|
|
||||||
{
|
|
||||||
"behandelstructuur": {
|
|
||||||
"duur": "string (bijv. '8 weken')",
|
|
||||||
"frequentie": "string (bijv. 'Wekelijks')",
|
|
||||||
"aantalSessies": number,
|
|
||||||
"vorm": "string (bijv. 'Individueel')"
|
|
||||||
},
|
|
||||||
"doelen": [
|
|
||||||
{
|
|
||||||
"id": "string (uuid)",
|
|
||||||
"title": "string (max 200 tekens, korte beschrijving)",
|
|
||||||
"description": "string (SMART uitwerking, 2-3 zinnen)",
|
|
||||||
"clientVersion": "string (B1-taal versie voor cliënt)",
|
|
||||||
"lifeDomain": "dlv|wonen|werk|sociaal|vrijetijd|financien|gezondheid",
|
|
||||||
"priority": "hoog|middel|laag",
|
|
||||||
"measurability": "string (hoe meten we vooruitgang?)",
|
|
||||||
"timelineWeeks": number,
|
|
||||||
"status": "niet_gestart",
|
|
||||||
"progress": 0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"interventies": [
|
|
||||||
{
|
|
||||||
"id": "string (uuid)",
|
|
||||||
"name": "string (bijv. 'CGT', 'EMDR')",
|
|
||||||
"description": "string (uitleg interventie)",
|
|
||||||
"rationale": "string (waarom past dit bij deze cliënt?)",
|
|
||||||
"linkedGoalIds": ["string (id van gekoppeld doel)"]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"sessiePlanning": [
|
|
||||||
{
|
|
||||||
"id": "string (uuid)",
|
|
||||||
"nummer": number,
|
|
||||||
"focus": "string (waar gaat de sessie over?)",
|
|
||||||
"status": "gepland",
|
|
||||||
"gekoppeldeDoelIds": ["string"]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"evaluatiemomenten": [
|
|
||||||
{
|
|
||||||
"id": "string (uuid)",
|
|
||||||
"type": "tussentijds|eind",
|
|
||||||
"weekNumber": number,
|
|
||||||
"plannedDate": "string (ISO date, mag leeg)",
|
|
||||||
"status": "gepland"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"veiligheidsplan": null of {
|
|
||||||
"waarschuwingssignalen": ["string (3-5 items)"],
|
|
||||||
"copingStrategieen": ["string (3-5 items)"],
|
|
||||||
"contacten": [
|
|
||||||
{
|
|
||||||
"naam": "string",
|
|
||||||
"rol": "string",
|
|
||||||
"telefoon": "string"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"restricties": ["string (optioneel)"]
|
|
||||||
}
|
|
||||||
}`;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bouw user prompt met context
|
|
||||||
*/
|
|
||||||
export function buildUserPrompt(context: PlanContext): string {
|
|
||||||
// Get high priority domains
|
|
||||||
const highPriorityDomains = context.lifeDomains.filter(d => d.priority === 'hoog');
|
|
||||||
const lowScoreDomains = context.lifeDomains.filter(d => d.baseline <= 2);
|
|
||||||
|
|
||||||
// Get intervention suggestions
|
|
||||||
const suggestedInterventions = getInterventionsForCategory(context.dsmCategory, context.severity);
|
|
||||||
const recommendedSessions = getRecommendedSessionCount(context.dsmCategory, context.severity);
|
|
||||||
const recommendedFrequency = getRecommendedFrequency(context.severity);
|
|
||||||
const recommendedFormat = getRecommendedFormat(context.severity);
|
|
||||||
|
|
||||||
// Build life domains section
|
|
||||||
const lifeDomainLines = context.lifeDomains.map(d => {
|
|
||||||
const meta = LIFE_DOMAIN_META[d.domain];
|
|
||||||
const priorityMarker = d.priority === 'hoog' ? ' [PRIORITEIT]' : '';
|
|
||||||
const lowScoreMarker = d.baseline <= 2 ? ' [LAAG]' : '';
|
|
||||||
return `- ${meta.label} (${d.domain}): ${d.baseline}/5 → doel ${d.target}/5${priorityMarker}${lowScoreMarker}${d.notes ? ` (${d.notes})` : ''}`;
|
|
||||||
}).join('\n');
|
|
||||||
|
|
||||||
// Build intervention suggestions
|
|
||||||
const interventionLines = suggestedInterventions.slice(0, 3).map(i =>
|
|
||||||
`- ${i.name}: ${i.description} (~${i.recommendedSessions} sessies)`
|
|
||||||
).join('\n');
|
|
||||||
|
|
||||||
return `CLIËNT CONTEXT:
|
|
||||||
=================
|
|
||||||
Intake notities:
|
|
||||||
${context.intakeNotes}
|
|
||||||
|
|
||||||
DSM-categorie: ${context.dsmCategory}
|
|
||||||
Severity: ${context.severity}
|
|
||||||
|
|
||||||
LEEFGEBIEDEN ASSESSMENT:
|
|
||||||
========================
|
|
||||||
${lifeDomainLines}
|
|
||||||
|
|
||||||
${highPriorityDomains.length > 0 ? `
|
|
||||||
FOCUS GEBIEDEN (hoge prioriteit):
|
|
||||||
${highPriorityDomains.map(d => `- ${LIFE_DOMAIN_META[d.domain].label}`).join('\n')}
|
|
||||||
` : ''}
|
|
||||||
${lowScoreDomains.length > 0 ? `
|
|
||||||
AANDACHTSPUNTEN (lage scores):
|
|
||||||
${lowScoreDomains.map(d => `- ${LIFE_DOMAIN_META[d.domain].label} (score: ${d.baseline}/5)`).join('\n')}
|
|
||||||
` : ''}
|
|
||||||
|
|
||||||
AANBEVOLEN INTERVENTIES (evidence-based):
|
|
||||||
=========================================
|
|
||||||
${interventionLines}
|
|
||||||
|
|
||||||
AANBEVOLEN BEHANDELSTRUCTUUR:
|
|
||||||
=============================
|
|
||||||
- Aantal sessies: ~${recommendedSessions}
|
|
||||||
- Frequentie: ${recommendedFrequency}
|
|
||||||
- Vorm: ${recommendedFormat}
|
|
||||||
|
|
||||||
${context.extraInstructions ? `
|
|
||||||
EXTRA INSTRUCTIES VAN BEHANDELAAR:
|
|
||||||
==================================
|
|
||||||
${context.extraInstructions}
|
|
||||||
` : ''}
|
|
||||||
|
|
||||||
Genereer nu een compleet behandelplan in JSON formaat.
|
|
||||||
Zorg dat elk doel:
|
|
||||||
1. Gekoppeld is aan een leefgebied met hoge prioriteit of lage score
|
|
||||||
2. Een meetbare indicator heeft
|
|
||||||
3. Een B1-taal versie heeft voor de cliënt
|
|
||||||
4. Realistisch is voor de behandelperiode
|
|
||||||
|
|
||||||
${context.severity === 'hoog' ? 'BELANGRIJK: Voeg ook een veiligheidsplan toe met waarschuwingssignalen, copingstrategieën en contactpersonen.' : ''}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Valideer of de context voldoende informatie bevat
|
|
||||||
*/
|
|
||||||
export function validatePlanContext(context: PlanContext): { valid: boolean; errors: string[] } {
|
|
||||||
const errors: string[] = [];
|
|
||||||
|
|
||||||
if (!context.intakeNotes || context.intakeNotes.length < 50) {
|
|
||||||
errors.push('Intake notities zijn te kort (minimaal 50 tekens)');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!context.dsmCategory) {
|
|
||||||
errors.push('DSM-categorie ontbreekt');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!context.severity) {
|
|
||||||
errors.push('Severity niveau ontbreekt');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!context.lifeDomains || context.lifeDomains.length !== 7) {
|
|
||||||
errors.push('Leefgebieden assessment is incompleet (7 domeinen vereist)');
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasHighPriority = context.lifeDomains?.some(d => d.priority === 'hoog');
|
|
||||||
if (!hasHighPriority) {
|
|
||||||
errors.push('Minimaal 1 leefgebied moet prioriteit "hoog" hebben');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
valid: errors.length === 0,
|
|
||||||
errors,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,232 +0,0 @@
|
|||||||
/**
|
|
||||||
* Evidence-Based Intervention Mapping
|
|
||||||
*
|
|
||||||
* Mapping van DSM-categorieën naar evidence-based interventies
|
|
||||||
* met sessie-aantallen per severity level
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface InterventionSuggestion {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
sessions: {
|
|
||||||
laag: number;
|
|
||||||
middel: number;
|
|
||||||
hoog: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Severity = 'laag' | 'middel' | 'hoog';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DSM categorieën naar interventie mapping
|
|
||||||
* Gebaseerd op richtlijnen GGZ Standaarden
|
|
||||||
*/
|
|
||||||
export const INTERVENTION_MAPPING: Record<string, InterventionSuggestion[]> = {
|
|
||||||
angststoornissen: [
|
|
||||||
{
|
|
||||||
name: 'CGT',
|
|
||||||
description: 'Cognitieve Gedragstherapie gericht op angstreductie door blootstelling en cognitieve herstructurering',
|
|
||||||
sessions: { laag: 8, middel: 10, hoog: 14 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Exposure therapie',
|
|
||||||
description: 'Systematische blootstelling aan angstopwekkende situaties met responspreventie',
|
|
||||||
sessions: { laag: 6, middel: 8, hoog: 12 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'ACT',
|
|
||||||
description: 'Acceptance and Commitment Therapy voor psychologische flexibiliteit',
|
|
||||||
sessions: { laag: 8, middel: 10, hoog: 12 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
stemmingsklachten: [
|
|
||||||
{
|
|
||||||
name: 'CGT',
|
|
||||||
description: 'Cognitieve Gedragstherapie gericht op negatieve denkpatronen en gedragsactivatie',
|
|
||||||
sessions: { laag: 8, middel: 10, hoog: 14 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'IPT',
|
|
||||||
description: 'Interpersoonlijke Therapie gericht op relationele patronen en sociale steun',
|
|
||||||
sessions: { laag: 8, middel: 12, hoog: 16 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Gedragsactivatie',
|
|
||||||
description: 'Gestructureerde toename van plezierige en betekenisvolle activiteiten',
|
|
||||||
sessions: { laag: 6, middel: 8, hoog: 10 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
trauma_ptss: [
|
|
||||||
{
|
|
||||||
name: 'EMDR',
|
|
||||||
description: 'Eye Movement Desensitization and Reprocessing voor traumaverwerking',
|
|
||||||
sessions: { laag: 6, middel: 10, hoog: 16 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Narratieve therapie',
|
|
||||||
description: 'Verwerking door het opbouwen van een coherent traumaverhaal',
|
|
||||||
sessions: { laag: 8, middel: 12, hoog: 16 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'CGT trauma-focus',
|
|
||||||
description: 'Trauma-gefocuste CGT met exposure en cognitieve verwerking',
|
|
||||||
sessions: { laag: 8, middel: 12, hoog: 16 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
persoonlijkheid: [
|
|
||||||
{
|
|
||||||
name: 'Schematherapie',
|
|
||||||
description: 'Langdurige therapie gericht op disfunctionele schemas en copingstijlen',
|
|
||||||
sessions: { laag: 16, middel: 24, hoog: 40 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'MBT',
|
|
||||||
description: 'Mentalization-Based Treatment voor emotieregulatie en relaties',
|
|
||||||
sessions: { laag: 16, middel: 24, hoog: 40 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'DGT',
|
|
||||||
description: 'Dialectische Gedragstherapie voor emotieregulatie en crisisvaardigheden',
|
|
||||||
sessions: { laag: 16, middel: 24, hoog: 40 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
verslaving: [
|
|
||||||
{
|
|
||||||
name: 'Motiverende gespreksvoering',
|
|
||||||
description: 'Versterken van intrinsieke motivatie voor gedragsverandering',
|
|
||||||
sessions: { laag: 4, middel: 6, hoog: 8 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'CGT verslaving',
|
|
||||||
description: 'Cognitieve Gedragstherapie gericht op craving en terugvalpreventie',
|
|
||||||
sessions: { laag: 8, middel: 12, hoog: 16 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Terugvalpreventie',
|
|
||||||
description: 'Identificeren en managen van risicosituaties en triggers',
|
|
||||||
sessions: { laag: 6, middel: 8, hoog: 12 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
adhd: [
|
|
||||||
{
|
|
||||||
name: 'Psycho-educatie',
|
|
||||||
description: 'Educatie over ADHD en praktische copingstrategieën',
|
|
||||||
sessions: { laag: 4, middel: 6, hoog: 8 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Coaching/planning',
|
|
||||||
description: 'Structuur en planningsvaardigheden ontwikkelen',
|
|
||||||
sessions: { laag: 6, middel: 8, hoog: 12 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'CGT ADHD',
|
|
||||||
description: 'Gedragstherapie gericht op impulscontrole en executieve functies',
|
|
||||||
sessions: { laag: 8, middel: 10, hoog: 14 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
autisme: [
|
|
||||||
{
|
|
||||||
name: 'Psycho-educatie',
|
|
||||||
description: 'Educatie over autisme en zelfacceptatie',
|
|
||||||
sessions: { laag: 4, middel: 6, hoog: 8 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Sociale vaardigheden',
|
|
||||||
description: 'Training in sociale interactie en communicatie',
|
|
||||||
sessions: { laag: 8, middel: 12, hoog: 16 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Stressmanagement',
|
|
||||||
description: 'Omgaan met prikkels en sensorische overbelasting',
|
|
||||||
sessions: { laag: 6, middel: 8, hoog: 12 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
overig: [
|
|
||||||
{
|
|
||||||
name: 'Ondersteunende gesprekken',
|
|
||||||
description: 'Steunende begeleiding en reflectie',
|
|
||||||
sessions: { laag: 4, middel: 6, hoog: 8 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Psycho-educatie',
|
|
||||||
description: 'Educatie over klachten en copingstrategieën',
|
|
||||||
sessions: { laag: 4, middel: 6, hoog: 8 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Haal interventies op voor een DSM categorie
|
|
||||||
*/
|
|
||||||
export function getInterventionsForCategory(
|
|
||||||
dsmCategory: string,
|
|
||||||
severity: Severity
|
|
||||||
): { name: string; description: string; recommendedSessions: number }[] {
|
|
||||||
// Normalize category
|
|
||||||
const normalizedCategory = dsmCategory
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z]/g, '_')
|
|
||||||
.replace(/_+/g, '_');
|
|
||||||
|
|
||||||
// Find matching category
|
|
||||||
const interventions =
|
|
||||||
INTERVENTION_MAPPING[normalizedCategory] ||
|
|
||||||
INTERVENTION_MAPPING['overig'];
|
|
||||||
|
|
||||||
return interventions.map((intervention) => ({
|
|
||||||
name: intervention.name,
|
|
||||||
description: intervention.description,
|
|
||||||
recommendedSessions: intervention.sessions[severity],
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bereken aanbevolen aantal sessies
|
|
||||||
*/
|
|
||||||
export function getRecommendedSessionCount(
|
|
||||||
dsmCategory: string,
|
|
||||||
severity: Severity
|
|
||||||
): number {
|
|
||||||
const interventions = getInterventionsForCategory(dsmCategory, severity);
|
|
||||||
if (interventions.length === 0) return 8;
|
|
||||||
|
|
||||||
// Neem gemiddelde van eerste 2 interventies
|
|
||||||
const topInterventions = interventions.slice(0, 2);
|
|
||||||
const avg =
|
|
||||||
topInterventions.reduce((sum, i) => sum + i.recommendedSessions, 0) /
|
|
||||||
topInterventions.length;
|
|
||||||
|
|
||||||
return Math.round(avg);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bepaal behandelvorm op basis van severity
|
|
||||||
*/
|
|
||||||
export function getRecommendedFormat(severity: Severity): string {
|
|
||||||
switch (severity) {
|
|
||||||
case 'laag':
|
|
||||||
return 'Individueel';
|
|
||||||
case 'middel':
|
|
||||||
return 'Individueel';
|
|
||||||
case 'hoog':
|
|
||||||
return 'Individueel + groep (optioneel)';
|
|
||||||
default:
|
|
||||||
return 'Individueel';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bepaal behandelfrequentie op basis van severity
|
|
||||||
*/
|
|
||||||
export function getRecommendedFrequency(severity: Severity): string {
|
|
||||||
switch (severity) {
|
|
||||||
case 'laag':
|
|
||||||
return 'Wekelijks tot tweewekelijks';
|
|
||||||
case 'middel':
|
|
||||||
return 'Wekelijks';
|
|
||||||
case 'hoog':
|
|
||||||
return 'Wekelijks tot 2x per week';
|
|
||||||
default:
|
|
||||||
return 'Wekelijks';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,620 +0,0 @@
|
|||||||
/**
|
|
||||||
* Behandelplan (Treatment Plan) Types
|
|
||||||
*
|
|
||||||
* Types voor AI-gegenereerde behandelplannen
|
|
||||||
* Gebaseerd op FHIR CarePlan met GGZ-specifieke uitbreidingen
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { z } from 'zod';
|
|
||||||
import { type LifeDomain, type LifeDomainScore, LIFE_DOMAINS, PRIORITIES } from './leefgebieden';
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// STATUS TYPES
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Status van een behandelplan
|
|
||||||
*/
|
|
||||||
export const PLAN_STATUSES = ['concept', 'actief', 'in_evaluatie', 'afgerond', 'gearchiveerd'] as const;
|
|
||||||
export type PlanStatus = typeof PLAN_STATUSES[number];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Status van een doel
|
|
||||||
*/
|
|
||||||
export const GOAL_STATUSES = ['niet_gestart', 'bezig', 'gehaald', 'bijgesteld'] as const;
|
|
||||||
export type GoalStatus = typeof GOAL_STATUSES[number];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Type evaluatiemoment
|
|
||||||
*/
|
|
||||||
export const EVALUATION_TYPES = ['tussentijds', 'eind', 'crisis'] as const;
|
|
||||||
export type EvaluationType = typeof EVALUATION_TYPES[number];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Status evaluatiemoment
|
|
||||||
*/
|
|
||||||
export const EVALUATION_STATUSES = ['gepland', 'afgerond', 'overgeslagen'] as const;
|
|
||||||
export type EvaluationStatus = typeof EVALUATION_STATUSES[number];
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// CORE TYPES
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Behandelstructuur - algemene parameters van het plan
|
|
||||||
*/
|
|
||||||
export interface Behandelstructuur {
|
|
||||||
duur: string; // bijv. "8 weken"
|
|
||||||
frequentie: string; // bijv. "Wekelijks"
|
|
||||||
aantalSessies: number; // bijv. 8
|
|
||||||
vorm: string; // bijv. "Individueel"
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SMART Doel
|
|
||||||
*/
|
|
||||||
export interface SmartGoal {
|
|
||||||
id: string;
|
|
||||||
title: string; // Korte beschrijving (1 zin)
|
|
||||||
description: string; // SMART-uitwerking (2-3 zinnen)
|
|
||||||
clientVersion: string; // B1-taal versie voor cliënt
|
|
||||||
lifeDomain: LifeDomain; // Gekoppeld leefgebied
|
|
||||||
priority: 'hoog' | 'middel' | 'laag';
|
|
||||||
measurability: string; // Hoe meten we vooruitgang?
|
|
||||||
timelineWeeks: number; // Binnen X weken
|
|
||||||
status: GoalStatus;
|
|
||||||
progress: number; // 0-100
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Evidence-based Interventie
|
|
||||||
*/
|
|
||||||
export interface Intervention {
|
|
||||||
id: string;
|
|
||||||
name: string; // bijv. "CGT", "EMDR", "ACT"
|
|
||||||
description: string; // Uitleg van de interventie
|
|
||||||
rationale: string; // Waarom past dit bij deze cliënt?
|
|
||||||
linkedGoalIds: string[]; // Welke doelen worden hiermee benaderd?
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Evaluatiemoment
|
|
||||||
*/
|
|
||||||
export interface Evaluatiemoment {
|
|
||||||
id: string;
|
|
||||||
type: EvaluationType;
|
|
||||||
weekNumber: number;
|
|
||||||
plannedDate: string; // ISO date string
|
|
||||||
actualDate?: string; // Ingevuld na uitvoering
|
|
||||||
status: EvaluationStatus;
|
|
||||||
outcome?: string; // Vrije tekst resultaat
|
|
||||||
lifeDomainUpdates?: LifeDomainScore[]; // Nieuwe scores
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Veiligheidsplan (alleen bij severity "Hoog")
|
|
||||||
*/
|
|
||||||
export interface Veiligheidsplan {
|
|
||||||
waarschuwingssignalen: string[]; // 3-5 items
|
|
||||||
copingStrategieen: string[]; // 3-5 items
|
|
||||||
contacten: {
|
|
||||||
naam: string;
|
|
||||||
rol: string;
|
|
||||||
telefoon: string;
|
|
||||||
}[];
|
|
||||||
restricties?: string[]; // bijv. "Geen alcohol tijdens behandeling"
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sessie in de planning
|
|
||||||
*/
|
|
||||||
export interface Sessie {
|
|
||||||
id: string;
|
|
||||||
nummer: number;
|
|
||||||
focus: string;
|
|
||||||
datum?: string; // ISO date string
|
|
||||||
status: 'gepland' | 'afgerond' | 'no_show' | 'verzet' | 'geannuleerd';
|
|
||||||
gekoppeldeDoelIds: string[];
|
|
||||||
notities?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// GENERATED PLAN (AI OUTPUT)
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Volledig door AI gegenereerd behandelplan
|
|
||||||
*/
|
|
||||||
export interface GeneratedPlan {
|
|
||||||
behandelstructuur: Behandelstructuur;
|
|
||||||
doelen: SmartGoal[];
|
|
||||||
interventies: Intervention[];
|
|
||||||
sessiePlanning: Sessie[];
|
|
||||||
evaluatiemomenten: Evaluatiemoment[];
|
|
||||||
veiligheidsplan?: Veiligheidsplan; // Alleen bij severity "Hoog"
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// API INPUT/OUTPUT TYPES
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Input voor behandelplan generatie
|
|
||||||
*/
|
|
||||||
export interface GenerateBehandelplanInput {
|
|
||||||
patientId: string;
|
|
||||||
intakeId: string;
|
|
||||||
conditionId?: string; // Optioneel, haalt anders laatste op
|
|
||||||
extraInstructions?: string; // Optionele aanvullende instructies
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Input voor micro-regeneratie
|
|
||||||
*/
|
|
||||||
export interface RegenerateSectionInput {
|
|
||||||
patientId: string;
|
|
||||||
carePlanId: string;
|
|
||||||
sectionType: 'goal' | 'intervention';
|
|
||||||
sectionId: string;
|
|
||||||
instruction?: string; // Extra instructie voor AI
|
|
||||||
currentPlan: GeneratedPlan; // Context van huidige plan
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Output van micro-regeneratie
|
|
||||||
*/
|
|
||||||
export interface RegeneratedSection {
|
|
||||||
type: 'goal' | 'intervention';
|
|
||||||
original: SmartGoal | Intervention;
|
|
||||||
regenerated: SmartGoal | Intervention;
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// ZOD SCHEMAS
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
export const BehandelstructuurSchema = z.object({
|
|
||||||
duur: z.string(),
|
|
||||||
frequentie: z.string(),
|
|
||||||
aantalSessies: z.number().min(1).max(52),
|
|
||||||
vorm: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const SmartGoalSchema = z.object({
|
|
||||||
id: z.string(),
|
|
||||||
title: z.string().min(5).max(200),
|
|
||||||
description: z.string().min(10).max(500),
|
|
||||||
clientVersion: z.string().min(5).max(300),
|
|
||||||
lifeDomain: z.enum(LIFE_DOMAINS),
|
|
||||||
priority: z.enum(PRIORITIES),
|
|
||||||
measurability: z.string().min(5).max(200),
|
|
||||||
timelineWeeks: z.number().min(1).max(52),
|
|
||||||
status: z.enum(GOAL_STATUSES),
|
|
||||||
progress: z.number().min(0).max(100),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const InterventionSchema = z.object({
|
|
||||||
id: z.string(),
|
|
||||||
name: z.string().min(2).max(100),
|
|
||||||
description: z.string().min(10).max(500),
|
|
||||||
rationale: z.string().min(10).max(500),
|
|
||||||
linkedGoalIds: z.array(z.string()),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const EvaluatiemomentSchema = z.object({
|
|
||||||
id: z.string(),
|
|
||||||
type: z.enum(EVALUATION_TYPES),
|
|
||||||
weekNumber: z.number().min(1).max(52),
|
|
||||||
plannedDate: z.string(),
|
|
||||||
actualDate: z.string().optional(),
|
|
||||||
status: z.enum(EVALUATION_STATUSES),
|
|
||||||
outcome: z.string().optional(),
|
|
||||||
lifeDomainUpdates: z.array(z.any()).optional(), // Simplified for now
|
|
||||||
});
|
|
||||||
|
|
||||||
export const VeiligheidsplanSchema = z.object({
|
|
||||||
waarschuwingssignalen: z.array(z.string()).min(1).max(10),
|
|
||||||
copingStrategieen: z.array(z.string()).min(1).max(10),
|
|
||||||
contacten: z.array(z.object({
|
|
||||||
naam: z.string(),
|
|
||||||
rol: z.string(),
|
|
||||||
telefoon: z.string(),
|
|
||||||
})),
|
|
||||||
restricties: z.array(z.string()).optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const SessieSchema = z.object({
|
|
||||||
id: z.string(),
|
|
||||||
nummer: z.number().min(1),
|
|
||||||
focus: z.string(),
|
|
||||||
datum: z.string().optional(),
|
|
||||||
status: z.enum(['gepland', 'afgerond', 'no_show', 'verzet', 'geannuleerd']),
|
|
||||||
gekoppeldeDoelIds: z.array(z.string()),
|
|
||||||
notities: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const GeneratedPlanSchema = z.object({
|
|
||||||
behandelstructuur: BehandelstructuurSchema,
|
|
||||||
doelen: z.array(SmartGoalSchema).min(1).max(6),
|
|
||||||
interventies: z.array(InterventionSchema).min(1).max(5),
|
|
||||||
sessiePlanning: z.array(SessieSchema),
|
|
||||||
evaluatiemomenten: z.array(EvaluatiemomentSchema).min(1),
|
|
||||||
veiligheidsplan: VeiligheidsplanSchema.optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const GenerateBehandelplanInputSchema = z.object({
|
|
||||||
patientId: z.string().uuid(),
|
|
||||||
intakeId: z.string().uuid(),
|
|
||||||
conditionId: z.string().uuid().optional(),
|
|
||||||
extraInstructions: z.string().max(500).optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const RegenerateSectionInputSchema = z.object({
|
|
||||||
patientId: z.string().uuid(),
|
|
||||||
carePlanId: z.string().uuid(),
|
|
||||||
sectionType: z.enum(['goal', 'intervention']),
|
|
||||||
sectionId: z.string(),
|
|
||||||
instruction: z.string().max(200).optional(),
|
|
||||||
currentPlan: GeneratedPlanSchema,
|
|
||||||
});
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// HELPER FUNCTIONS
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Genereer een nieuw UUID-achtig ID
|
|
||||||
*/
|
|
||||||
export function generateId(): string {
|
|
||||||
return crypto.randomUUID();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maak een nieuw leeg SMART doel
|
|
||||||
*/
|
|
||||||
export function createEmptyGoal(lifeDomain: LifeDomain = 'dlv'): SmartGoal {
|
|
||||||
return {
|
|
||||||
id: generateId(),
|
|
||||||
title: '',
|
|
||||||
description: '',
|
|
||||||
clientVersion: '',
|
|
||||||
lifeDomain,
|
|
||||||
priority: 'middel',
|
|
||||||
measurability: '',
|
|
||||||
timelineWeeks: 8,
|
|
||||||
status: 'niet_gestart',
|
|
||||||
progress: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maak een nieuwe lege interventie
|
|
||||||
*/
|
|
||||||
export function createEmptyIntervention(): Intervention {
|
|
||||||
return {
|
|
||||||
id: generateId(),
|
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
rationale: '',
|
|
||||||
linkedGoalIds: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bereken totale voortgang van alle doelen
|
|
||||||
*/
|
|
||||||
export function calculateTotalProgress(goals: SmartGoal[]): number {
|
|
||||||
if (goals.length === 0) return 0;
|
|
||||||
const sum = goals.reduce((acc, goal) => acc + goal.progress, 0);
|
|
||||||
return Math.round(sum / goals.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Krijg doelen per leefgebied
|
|
||||||
*/
|
|
||||||
export function getGoalsByDomain(goals: SmartGoal[]): Record<LifeDomain, SmartGoal[]> {
|
|
||||||
const result = {} as Record<LifeDomain, SmartGoal[]>;
|
|
||||||
for (const domain of LIFE_DOMAINS) {
|
|
||||||
result[domain] = goals.filter((g) => g.lifeDomain === domain);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check of plan klaar is voor publicatie
|
|
||||||
*/
|
|
||||||
export function canPublish(plan: GeneratedPlan): { valid: boolean; errors: string[] } {
|
|
||||||
const errors: string[] = [];
|
|
||||||
|
|
||||||
if (plan.doelen.length === 0) {
|
|
||||||
errors.push('Minimaal 1 doel is vereist');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (plan.interventies.length === 0) {
|
|
||||||
errors.push('Minimaal 1 interventie is vereist');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!plan.behandelstructuur.duur || !plan.behandelstructuur.frequentie) {
|
|
||||||
errors.push('Behandelstructuur moet compleet zijn');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (plan.evaluatiemomenten.length < 2) {
|
|
||||||
errors.push('Minimaal 2 evaluatiemomenten zijn vereist');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
valid: errors.length === 0,
|
|
||||||
errors,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Status label voor UI (oude Nederlandse keys - deprecated)
|
|
||||||
*/
|
|
||||||
export const PLAN_STATUS_LABELS: Record<PlanStatus, { label: string; color: string }> = {
|
|
||||||
concept: { label: 'Concept', color: '#60a5fa' }, // blauw
|
|
||||||
actief: { label: 'Actief', color: '#10b981' }, // groen
|
|
||||||
in_evaluatie: { label: 'In evaluatie', color: '#f59e0b' }, // oranje
|
|
||||||
afgerond: { label: 'Afgerond', color: '#6b7280' }, // grijs
|
|
||||||
gearchiveerd: { label: 'Gearchiveerd', color: '#9ca3af' }, // lichtgrijs
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* FHIR status naar Nederlandse UI labels
|
|
||||||
* Database gebruikt FHIR statussen, UI toont Nederlands
|
|
||||||
*/
|
|
||||||
export type FhirCarePlanStatus = 'draft' | 'active' | 'on-hold' | 'completed' | 'revoked' | 'entered-in-error' | 'unknown';
|
|
||||||
|
|
||||||
export const FHIR_STATUS_LABELS: Record<FhirCarePlanStatus, { label: string; color: string }> = {
|
|
||||||
draft: { label: 'Concept', color: '#60a5fa' }, // blauw
|
|
||||||
active: { label: 'Actueel', color: '#10b981' }, // groen
|
|
||||||
'on-hold': { label: 'Gepauzeerd', color: '#f59e0b' }, // oranje
|
|
||||||
completed: { label: 'Definitief', color: '#6b7280' }, // grijs
|
|
||||||
revoked: { label: 'Archief', color: '#9ca3af' }, // lichtgrijs
|
|
||||||
'entered-in-error': { label: 'Verwijderd', color: '#ef4444' }, // rood
|
|
||||||
unknown: { label: 'Onbekend', color: '#9ca3af' }, // lichtgrijs
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Goal status label voor UI
|
|
||||||
*/
|
|
||||||
export const GOAL_STATUS_LABELS: Record<GoalStatus, { label: string; color: string }> = {
|
|
||||||
niet_gestart: { label: 'Niet gestart', color: '#9ca3af' },
|
|
||||||
bezig: { label: 'Bezig', color: '#3b82f6' },
|
|
||||||
gehaald: { label: 'Gehaald', color: '#10b981' },
|
|
||||||
bijgesteld: { label: 'Bijgesteld', color: '#f59e0b' },
|
|
||||||
};
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// FLAT BEHANDELPLAN TYPES (Nieuwe platte structuur)
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Embedded interventie binnen een behandeldoel
|
|
||||||
* Simpelere versie zonder linkedGoalIds (want embedded in doel)
|
|
||||||
*/
|
|
||||||
export interface EmbeddedInterventie {
|
|
||||||
id: string;
|
|
||||||
name: string; // bijv. "CGT", "EMDR", "ACT"
|
|
||||||
description: string; // Korte beschrijving van de aanpak
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Behandeldoel met embedded interventies
|
|
||||||
* Kerntype voor de "platte" behandelplan structuur
|
|
||||||
*/
|
|
||||||
export interface Behandeldoel {
|
|
||||||
id: string;
|
|
||||||
|
|
||||||
// Doel informatie
|
|
||||||
title: string; // Professionele formulering
|
|
||||||
clientVersion: string; // B1-taal versie voor cliënt
|
|
||||||
|
|
||||||
// Classificatie
|
|
||||||
lifeDomain: LifeDomain; // Gekoppeld leefgebied
|
|
||||||
|
|
||||||
// Embedded interventies (KERNVERANDERING - niet meer apart)
|
|
||||||
interventies: EmbeddedInterventie[];
|
|
||||||
|
|
||||||
// Timeline
|
|
||||||
startWeek: number; // Start week (1-52)
|
|
||||||
endWeek: number; // Eind week (1-52)
|
|
||||||
|
|
||||||
// Status & voortgang
|
|
||||||
status: GoalStatus;
|
|
||||||
progress: number; // 0-100
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// FLAT BEHANDELPLAN ZOD SCHEMAS
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
export const EmbeddedInterventieSchema = z.object({
|
|
||||||
id: z.string(),
|
|
||||||
name: z.string().min(1).max(100),
|
|
||||||
description: z.string().max(500),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const BehandeldoelSchema = z.object({
|
|
||||||
id: z.string(),
|
|
||||||
title: z.string().min(5).max(200),
|
|
||||||
clientVersion: z.string().min(5).max(300),
|
|
||||||
lifeDomain: z.enum(LIFE_DOMAINS),
|
|
||||||
interventies: z.array(EmbeddedInterventieSchema),
|
|
||||||
startWeek: z.number().min(1).max(52),
|
|
||||||
endWeek: z.number().min(1).max(52),
|
|
||||||
status: z.enum(GOAL_STATUSES),
|
|
||||||
progress: z.number().min(0).max(100),
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Schema voor AI-gegenereerd flat behandelplan
|
|
||||||
*/
|
|
||||||
export const GeneratedPlanFlatSchema = z.object({
|
|
||||||
behandelstructuur: BehandelstructuurSchema,
|
|
||||||
behandeldoelen: z.array(BehandeldoelSchema).min(1).max(6),
|
|
||||||
evaluatiemomenten: z.array(EvaluatiemomentSchema).min(1),
|
|
||||||
veiligheidsplan: VeiligheidsplanSchema.optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type GeneratedPlanFlat = z.infer<typeof GeneratedPlanFlatSchema>;
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// TRANSFORMATIE FUNCTIES (Oude <-> Nieuwe structuur)
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Transformeer oude structuur (goals + interventions apart) naar flat (behandeldoelen)
|
|
||||||
* Koppelt interventies aan doelen op basis van linkedGoalIds
|
|
||||||
*/
|
|
||||||
export function transformToFlat(
|
|
||||||
goals: SmartGoal[],
|
|
||||||
interventions: Intervention[]
|
|
||||||
): Behandeldoel[] {
|
|
||||||
return goals.map((goal) => {
|
|
||||||
// Vind interventies die aan dit doel gekoppeld zijn
|
|
||||||
const linkedInterventions = interventions
|
|
||||||
.filter((int) => int.linkedGoalIds.includes(goal.id))
|
|
||||||
.map((int) => ({
|
|
||||||
id: int.id,
|
|
||||||
name: int.name,
|
|
||||||
description: int.description,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: goal.id,
|
|
||||||
title: goal.title,
|
|
||||||
clientVersion: goal.clientVersion,
|
|
||||||
lifeDomain: goal.lifeDomain,
|
|
||||||
interventies: linkedInterventions,
|
|
||||||
startWeek: 1, // Default, kan later uit goal.timelineWeeks berekend worden
|
|
||||||
endWeek: goal.timelineWeeks,
|
|
||||||
status: goal.status,
|
|
||||||
progress: goal.progress,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Transformeer flat structuur terug naar oude structuur (voor backwards compatibility)
|
|
||||||
* Zet embedded interventies om naar aparte array met linkedGoalIds
|
|
||||||
*/
|
|
||||||
export function transformFromFlat(behandeldoelen: Behandeldoel[]): {
|
|
||||||
goals: SmartGoal[];
|
|
||||||
interventions: Intervention[];
|
|
||||||
} {
|
|
||||||
const goals: SmartGoal[] = [];
|
|
||||||
const interventionMap = new Map<string, Intervention>();
|
|
||||||
|
|
||||||
for (const doel of behandeldoelen) {
|
|
||||||
// Maak SmartGoal van Behandeldoel
|
|
||||||
goals.push({
|
|
||||||
id: doel.id,
|
|
||||||
title: doel.title,
|
|
||||||
description: '', // Niet meer gebruikt in flat structuur
|
|
||||||
clientVersion: doel.clientVersion,
|
|
||||||
lifeDomain: doel.lifeDomain,
|
|
||||||
priority: 'middel', // Default
|
|
||||||
measurability: '', // Niet meer gebruikt in flat structuur
|
|
||||||
timelineWeeks: doel.endWeek,
|
|
||||||
status: doel.status,
|
|
||||||
progress: doel.progress,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Verzamel interventies en koppel aan doel
|
|
||||||
for (const int of doel.interventies) {
|
|
||||||
const existing = interventionMap.get(int.id);
|
|
||||||
if (existing) {
|
|
||||||
// Interventie bestaat al, voeg dit doel toe aan linkedGoalIds
|
|
||||||
existing.linkedGoalIds.push(doel.id);
|
|
||||||
} else {
|
|
||||||
// Nieuwe interventie
|
|
||||||
interventionMap.set(int.id, {
|
|
||||||
id: int.id,
|
|
||||||
name: int.name,
|
|
||||||
description: int.description,
|
|
||||||
rationale: '', // Niet meer gebruikt in flat structuur
|
|
||||||
linkedGoalIds: [doel.id],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
goals,
|
|
||||||
interventions: Array.from(interventionMap.values()),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maak een nieuw leeg behandeldoel
|
|
||||||
*/
|
|
||||||
export function createEmptyBehandeldoel(lifeDomain: LifeDomain = 'dlv'): Behandeldoel {
|
|
||||||
return {
|
|
||||||
id: generateId(),
|
|
||||||
title: '',
|
|
||||||
clientVersion: '',
|
|
||||||
lifeDomain,
|
|
||||||
interventies: [],
|
|
||||||
startWeek: 1,
|
|
||||||
endWeek: 8,
|
|
||||||
status: 'niet_gestart',
|
|
||||||
progress: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maak een nieuwe lege embedded interventie
|
|
||||||
*/
|
|
||||||
export function createEmptyEmbeddedInterventie(): EmbeddedInterventie {
|
|
||||||
return {
|
|
||||||
id: generateId(),
|
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bereken totale voortgang van behandeldoelen
|
|
||||||
*/
|
|
||||||
export function calculateBehandeldoelenProgress(doelen: Behandeldoel[]): number {
|
|
||||||
if (doelen.length === 0) return 0;
|
|
||||||
const sum = doelen.reduce((acc, doel) => acc + doel.progress, 0);
|
|
||||||
return Math.round(sum / doelen.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check of flat plan klaar is voor publicatie
|
|
||||||
*/
|
|
||||||
export function canPublishFlat(
|
|
||||||
behandeldoelen: Behandeldoel[],
|
|
||||||
behandelstructuur: Behandelstructuur,
|
|
||||||
evaluatiemomenten: Evaluatiemoment[]
|
|
||||||
): { valid: boolean; errors: string[] } {
|
|
||||||
const errors: string[] = [];
|
|
||||||
|
|
||||||
if (behandeldoelen.length === 0) {
|
|
||||||
errors.push('Minimaal 1 behandeldoel is vereist');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check of elk doel minimaal 1 interventie heeft
|
|
||||||
const doelenZonderInterventie = behandeldoelen.filter(
|
|
||||||
(d) => d.interventies.length === 0
|
|
||||||
);
|
|
||||||
if (doelenZonderInterventie.length > 0) {
|
|
||||||
errors.push('Elk behandeldoel moet minimaal 1 interventie hebben');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!behandelstructuur.duur || !behandelstructuur.frequentie) {
|
|
||||||
errors.push('Behandelstructuur moet compleet zijn');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (evaluatiemomenten.length < 2) {
|
|
||||||
errors.push('Minimaal 2 evaluatiemomenten zijn vereist');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
valid: errors.length === 0,
|
|
||||||
errors,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user