feat(diagnose+agenda): Diagnose module met ICD-10 en agenda uitbreidingen
Diagnose Module: - Diagnose overzicht pagina met alle patiënt diagnoses - Diagnosis manager met ICD-10 combobox zoekfunctie - Diagnose kaarten met hoofddiagnose markering - Modal voor nieuwe/bewerkte diagnoses - ICD-10 GGZ codes dataset (lib/data/) - Zod schemas voor diagnose validatie - TypeScript types voor ICD-10 (lib/types/icd10.ts) - Complete documentatie (PRD, FO, TO, Bouwplan) Agenda Uitbreidingen: - Patient context card in afspraak modal - Rapportage composer direct in afspraak modal - Rapportage bewerken vanuit gekoppelde rapportages - Verbeterde focus styling voor inputs Behandelplan: - Flat componenten structuur (behandeldoel-card, form, planning) - Context header component - Uitgebreide types (lib/types/behandelplan.ts) - Actions voor behandelplan beheer UI Componenten: - Command component (shadcn/ui) voor combobox - Popover component (shadcn/ui) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,8 @@
|
||||
|
||||
import { createClient } from '@/lib/auth/server';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import type { GeneratedPlan, SmartGoal, Intervention, Behandelstructuur } from '@/lib/types/behandelplan';
|
||||
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';
|
||||
|
||||
@@ -518,3 +519,132 @@ export async function deleteIntervention(
|
||||
|
||||
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`);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
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,
|
||||
@@ -14,10 +15,14 @@ import {
|
||||
addIntervention,
|
||||
updateIntervention,
|
||||
deleteIntervention,
|
||||
saveBehandeldoel,
|
||||
deleteBehandeldoel,
|
||||
} from './actions';
|
||||
import type { GeneratedPlan, SmartGoal, Intervention, Sessie, Evaluatiemoment, Veiligheidsplan, Behandelstructuur } from '@/lib/types/behandelplan';
|
||||
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 {
|
||||
@@ -124,6 +129,9 @@ export function BehandelplanPageClient({
|
||||
}: 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>(
|
||||
@@ -380,41 +388,131 @@ export function BehandelplanPageClient({
|
||||
[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">
|
||||
{/* Plannen overzicht */}
|
||||
<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}
|
||||
/>
|
||||
{/* 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}
|
||||
/>
|
||||
|
||||
{/* Geselecteerd plan of create view */}
|
||||
<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}
|
||||
/>
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
|
||||
71
app/epd/patients/[id]/diagnose/actions.ts
Normal file
71
app/epd/patients/[id]/diagnose/actions.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
'use server';
|
||||
|
||||
import { createClient } from '@/lib/auth/server';
|
||||
import type { Database } from '@/lib/supabase/database.types';
|
||||
|
||||
export type Condition = Database['public']['Tables']['conditions']['Row'];
|
||||
|
||||
export type IntakeInfo = {
|
||||
id: string;
|
||||
title: string | null;
|
||||
department: string | null;
|
||||
start_date: string | null;
|
||||
};
|
||||
|
||||
export type DiagnosisWithIntake = Condition & {
|
||||
intake?: IntakeInfo | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Haal alle diagnoses op voor een patiënt (uit alle intakes)
|
||||
*/
|
||||
export async function getPatientDiagnoses(patientId: string): Promise<DiagnosisWithIntake[]> {
|
||||
const supabase = await createClient();
|
||||
|
||||
// Haal eerst alle diagnoses op
|
||||
const { data: conditions, error: conditionsError } = await supabase
|
||||
.from('conditions')
|
||||
.select('*')
|
||||
.eq('patient_id', patientId)
|
||||
.order('recorded_date', { ascending: false });
|
||||
|
||||
if (conditionsError) {
|
||||
console.error('getPatientDiagnoses error', conditionsError);
|
||||
throw new Error('Kon diagnoses niet ophalen');
|
||||
}
|
||||
|
||||
if (!conditions || conditions.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Haal de intake IDs op
|
||||
const intakeIds = [...new Set(conditions.map((c) => c.encounter_id).filter((id): id is string => id !== null))];
|
||||
|
||||
if (intakeIds.length === 0) {
|
||||
return conditions.map((c) => ({ ...c, intake: null }));
|
||||
}
|
||||
|
||||
// Haal intake informatie op
|
||||
const { data: intakes, error: intakesError } = await supabase
|
||||
.from('intakes')
|
||||
.select('id, title, department, start_date')
|
||||
.in('id', intakeIds);
|
||||
|
||||
if (intakesError) {
|
||||
console.error('getPatientDiagnoses intakes error', intakesError);
|
||||
// Return conditions zonder intake info als de query faalt
|
||||
return conditions.map((c) => ({ ...c, intake: null }));
|
||||
}
|
||||
|
||||
// Maak lookup map
|
||||
const intakeMap = new Map<string, IntakeInfo>();
|
||||
intakes?.forEach((intake) => {
|
||||
intakeMap.set(intake.id, intake);
|
||||
});
|
||||
|
||||
// Combineer data
|
||||
return conditions.map((condition) => ({
|
||||
...condition,
|
||||
intake: condition.encounter_id ? intakeMap.get(condition.encounter_id) || null : null,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Diagnosis Overview Card Component
|
||||
*
|
||||
* Read-only weergave van een diagnose voor het patiënt-breed overzicht.
|
||||
* Toont ook de gekoppelde intake informatie.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { format } from 'date-fns';
|
||||
import { nl } from 'date-fns/locale';
|
||||
import { ChevronDown, ChevronUp, ExternalLink } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { DiagnosisWithIntake } from '../actions';
|
||||
|
||||
interface DiagnosisOverviewCardProps {
|
||||
diagnosis: DiagnosisWithIntake;
|
||||
patientId: string;
|
||||
}
|
||||
|
||||
// Status badge configuratie
|
||||
const STATUS_CONFIG: Record<string, { label: string; color: string; bgColor: string }> = {
|
||||
active: {
|
||||
label: 'Actief',
|
||||
color: 'text-green-700',
|
||||
bgColor: 'bg-green-100 border-green-300',
|
||||
},
|
||||
remission: {
|
||||
label: 'In remissie',
|
||||
color: 'text-blue-700',
|
||||
bgColor: 'bg-blue-100 border-blue-300',
|
||||
},
|
||||
resolved: {
|
||||
label: 'Opgelost',
|
||||
color: 'text-slate-700',
|
||||
bgColor: 'bg-slate-100 border-slate-300',
|
||||
},
|
||||
inactive: {
|
||||
label: 'Inactief',
|
||||
color: 'text-amber-700',
|
||||
bgColor: 'bg-amber-100 border-amber-300',
|
||||
},
|
||||
};
|
||||
|
||||
// Severity labels
|
||||
const SEVERITY_LABELS: Record<string, string> = {
|
||||
licht: 'Licht',
|
||||
matig: 'Matig',
|
||||
ernstig: 'Ernstig',
|
||||
};
|
||||
|
||||
export function DiagnosisOverviewCard({ diagnosis, patientId }: DiagnosisOverviewCardProps) {
|
||||
const [isNotesExpanded, setIsNotesExpanded] = useState(false);
|
||||
|
||||
const code = diagnosis.code_code || '';
|
||||
const description = diagnosis.code_display || '';
|
||||
const severity = diagnosis.severity_display || '';
|
||||
const status = diagnosis.clinical_status || 'active';
|
||||
const notes = diagnosis.note || '';
|
||||
const recordedDate = diagnosis.recorded_date ? new Date(diagnosis.recorded_date) : null;
|
||||
const isPrimary = diagnosis.category === 'primary-diagnosis';
|
||||
|
||||
const statusConfig = STATUS_CONFIG[status] || STATUS_CONFIG.active;
|
||||
const hasNotes = notes.trim().length > 0;
|
||||
|
||||
// Intake informatie
|
||||
const intake = diagnosis.intake;
|
||||
const intakeUrl = intake
|
||||
? `/epd/patients/${patientId}/intakes/${intake.id}/diagnosis`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Card className="transition-all hover:border-teal-300 hover:shadow-sm">
|
||||
<CardHeader className="p-4 pb-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
{/* Code + beschrijving */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-slate-900">
|
||||
{code && description ? (
|
||||
<>
|
||||
<span className="font-mono">{code}</span>
|
||||
{' — '}
|
||||
<span>{description}</span>
|
||||
</>
|
||||
) : (
|
||||
code || description || 'Geen diagnose code'
|
||||
)}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Badges */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{/* HOOFD badge */}
|
||||
{isPrimary && (
|
||||
<Badge className="bg-green-600 text-white border-green-700 hover:bg-green-600">
|
||||
HOOFD
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* Status badge */}
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-xs ${statusConfig.color} ${statusConfig.bgColor}`}
|
||||
>
|
||||
{statusConfig.label}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-4 pt-0 space-y-3">
|
||||
{/* Meta informatie */}
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm text-slate-600">
|
||||
{severity && (
|
||||
<div>
|
||||
<span className="font-medium">Ernst:</span>{' '}
|
||||
<span>{SEVERITY_LABELS[severity] || severity}</span>
|
||||
</div>
|
||||
)}
|
||||
{recordedDate && (
|
||||
<div>
|
||||
<span className="font-medium">Datum:</span>{' '}
|
||||
<span>{format(recordedDate, 'd MMM yyyy', { locale: nl })}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Intake link */}
|
||||
{intake && intakeUrl && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-slate-500">Intake:</span>
|
||||
<Link
|
||||
href={intakeUrl}
|
||||
className="text-teal-600 hover:text-teal-700 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{intake.title || intake.department || 'Intake'}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Onderbouwing (expand/collapse) */}
|
||||
{hasNotes && (
|
||||
<div className="border-t border-slate-100 pt-3">
|
||||
<button
|
||||
onClick={() => setIsNotesExpanded(!isNotesExpanded)}
|
||||
className="flex items-center gap-2 w-full text-left text-sm font-medium text-slate-700 hover:text-slate-900 transition-colors"
|
||||
>
|
||||
{isNotesExpanded ? (
|
||||
<ChevronUp className="h-4 w-4 text-slate-500" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-slate-500" />
|
||||
)}
|
||||
<span>Onderbouwing</span>
|
||||
</button>
|
||||
{isNotesExpanded && (
|
||||
<div className="mt-2 pl-6 text-sm text-slate-600 whitespace-pre-line">
|
||||
{notes}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bewerk link naar intake */}
|
||||
{intakeUrl && (
|
||||
<div className="pt-2">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={intakeUrl}>
|
||||
Bewerken in intake
|
||||
<ExternalLink className="ml-2 h-3 w-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +1,145 @@
|
||||
/**
|
||||
* Diagnose Page
|
||||
* E2.S3: Placeholder for diagnose functionality (to be implemented in Epic 6)
|
||||
* Diagnose Overzicht Pagina
|
||||
*
|
||||
* Toont alle diagnoses van een patiënt (uit alle intakes).
|
||||
* Diagnoses kunnen worden bewerkt via de gekoppelde intake.
|
||||
*/
|
||||
|
||||
import { Stethoscope } from 'lucide-react';
|
||||
import { Stethoscope, Plus } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { getPatientDiagnoses } from './actions';
|
||||
import { DiagnosisOverviewCard } from './components/diagnosis-overview-card';
|
||||
import { createClient } from '@/lib/auth/server';
|
||||
|
||||
export default async function DiagnosePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const { id: patientId } = await params;
|
||||
|
||||
// Haal alle diagnoses op
|
||||
const diagnoses = await getPatientDiagnoses(patientId);
|
||||
|
||||
// Sorteer: hoofddiagnoses eerst, dan actieve, dan op datum
|
||||
const sortedDiagnoses = [...diagnoses].sort((a, b) => {
|
||||
// Hoofddiagnoses eerst
|
||||
const aIsPrimary = a.category === 'primary-diagnosis';
|
||||
const bIsPrimary = b.category === 'primary-diagnosis';
|
||||
if (aIsPrimary && !bIsPrimary) return -1;
|
||||
if (!aIsPrimary && bIsPrimary) return 1;
|
||||
|
||||
// Actieve diagnoses eerst
|
||||
const aIsActive = a.clinical_status === 'active';
|
||||
const bIsActive = b.clinical_status === 'active';
|
||||
if (aIsActive && !bIsActive) return -1;
|
||||
if (!aIsActive && bIsActive) return 1;
|
||||
|
||||
// Dan op datum (nieuwste eerst)
|
||||
const aDate = a.recorded_date ? new Date(a.recorded_date).getTime() : 0;
|
||||
const bDate = b.recorded_date ? new Date(b.recorded_date).getTime() : 0;
|
||||
return bDate - aDate;
|
||||
});
|
||||
|
||||
// Tel actieve diagnoses
|
||||
const activeDiagnoses = diagnoses.filter((d) => d.clinical_status === 'active');
|
||||
const primaryDiagnosis = diagnoses.find((d) => d.category === 'primary-diagnosis');
|
||||
|
||||
// Haal meest recente intake op voor "Nieuwe diagnose" link
|
||||
const supabase = await createClient();
|
||||
const { data: recentIntake } = await supabase
|
||||
.from('intakes')
|
||||
.select('id')
|
||||
.eq('patient_id', patientId)
|
||||
.order('start_date', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
|
||||
const newDiagnosisUrl = recentIntake
|
||||
? `/epd/patients/${patientId}/intakes/${recentIntake.id}/diagnosis`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Page Header */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900">Diagnose</h2>
|
||||
<p className="text-sm text-slate-600 mt-1">
|
||||
DSM-5 diagnoses en behandeladvies
|
||||
</p>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">Diagnoses</h2>
|
||||
<p className="text-sm text-slate-600 mt-1">
|
||||
Overzicht van alle diagnoses (ICD-10) voor deze patiënt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{newDiagnosisUrl && (
|
||||
<Button asChild>
|
||||
<Link href={newDiagnosisUrl}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Nieuwe diagnose
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Placeholder */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-12 text-center">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-purple-50 mb-4">
|
||||
<Stethoscope className="h-8 w-8 text-purple-500" />
|
||||
{/* Summary cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-4">
|
||||
<div className="text-2xl font-bold text-slate-900">{diagnoses.length}</div>
|
||||
<div className="text-sm text-slate-600">Totaal diagnoses</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-4">
|
||||
<div className="text-2xl font-bold text-green-600">{activeDiagnoses.length}</div>
|
||||
<div className="text-sm text-slate-600">Actieve diagnoses</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-4">
|
||||
<div className="text-sm font-medium text-slate-900 truncate">
|
||||
{primaryDiagnosis ? (
|
||||
<>
|
||||
<span className="font-mono">{primaryDiagnosis.code_code}</span>
|
||||
{' — '}
|
||||
{primaryDiagnosis.code_display}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-slate-400">Geen hoofddiagnose</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-slate-600">Hoofddiagnose</div>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-2">
|
||||
Diagnose Module - Coming Soon
|
||||
</h3>
|
||||
<p className="text-sm text-slate-600 max-w-md mx-auto">
|
||||
De diagnose functionaliteit wordt geïmplementeerd in Epic 6. Dit omvat
|
||||
DSM-5 diagnose registratie en behandeladvies formulering.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Diagnoses lijst */}
|
||||
{sortedDiagnoses.length === 0 ? (
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-12 text-center">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-purple-50 mb-4">
|
||||
<Stethoscope className="h-8 w-8 text-purple-500" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-2">
|
||||
Nog geen diagnoses
|
||||
</h3>
|
||||
<p className="text-sm text-slate-600 max-w-md mx-auto mb-4">
|
||||
Er zijn nog geen diagnoses geregistreerd voor deze patiënt.
|
||||
Diagnoses worden vastgelegd tijdens een intake.
|
||||
</p>
|
||||
{newDiagnosisUrl && (
|
||||
<Button asChild>
|
||||
<Link href={newDiagnosisUrl}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Eerste diagnose toevoegen
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{sortedDiagnoses.map((diagnosis) => (
|
||||
<DiagnosisOverviewCard
|
||||
key={diagnosis.id}
|
||||
diagnosis={diagnosis}
|
||||
patientId={patientId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -312,14 +312,17 @@ export async function getDiagnoses(intakeId: string) {
|
||||
return data || [];
|
||||
}
|
||||
|
||||
type ClinicalStatus = 'active' | 'recurrence' | 'relapse' | 'inactive' | 'remission' | 'resolved';
|
||||
|
||||
export interface DiagnosisPayload {
|
||||
patientId: string;
|
||||
intakeId: string;
|
||||
code: string;
|
||||
description: string;
|
||||
severity?: string;
|
||||
status?: string;
|
||||
status?: ClinicalStatus;
|
||||
notes?: string;
|
||||
diagnosisType?: 'primary' | 'secondary';
|
||||
}
|
||||
|
||||
export async function createDiagnosis(payload: DiagnosisPayload) {
|
||||
@@ -329,26 +332,98 @@ export async function createDiagnosis(payload: DiagnosisPayload) {
|
||||
encounter_id: payload.intakeId,
|
||||
code_code: payload.code,
|
||||
code_display: payload.description,
|
||||
code_system: 'DSM-5',
|
||||
clinical_status: 'active',
|
||||
code_system: 'ICD-10',
|
||||
clinical_status: payload.status || 'active',
|
||||
severity_display: payload.severity || null,
|
||||
category: payload.diagnosisType === 'primary' ? 'primary-diagnosis' : 'encounter-diagnosis',
|
||||
note: payload.notes,
|
||||
recorded_date: new Date().toISOString(),
|
||||
});
|
||||
if (error) {
|
||||
console.error('createDiagnosis error', error);
|
||||
throw new Error(error.message);
|
||||
throw new Error('Diagnose opslaan mislukt');
|
||||
}
|
||||
revalidatePath(buildPath(payload.patientId, payload.intakeId, 'diagnosis'));
|
||||
revalidatePath(buildPath(payload.patientId, payload.intakeId));
|
||||
}
|
||||
|
||||
export interface DiagnosisUpdatePayload {
|
||||
code?: string;
|
||||
description?: string;
|
||||
severity?: string;
|
||||
status?: string;
|
||||
notes?: string;
|
||||
diagnosisType?: 'primary' | 'secondary';
|
||||
}
|
||||
|
||||
export async function updateDiagnosis(
|
||||
diagnosisId: string,
|
||||
payload: DiagnosisUpdatePayload
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const supabase = await getSupabase();
|
||||
|
||||
// Haal eerst de diagnosis op om patientId en intakeId te krijgen voor revalidatie
|
||||
const { data: diagnosis, error: fetchError } = await supabase
|
||||
.from('conditions')
|
||||
.select('patient_id, encounter_id')
|
||||
.eq('id', diagnosisId)
|
||||
.single();
|
||||
|
||||
if (fetchError || !diagnosis) {
|
||||
console.error('updateDiagnosis fetch error', fetchError);
|
||||
return { success: false, error: 'Diagnose niet gevonden' };
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = {
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (payload.code !== undefined) {
|
||||
updateData.code_code = payload.code;
|
||||
}
|
||||
if (payload.description !== undefined) {
|
||||
updateData.code_display = payload.description;
|
||||
}
|
||||
if (payload.status !== undefined) {
|
||||
updateData.clinical_status = payload.status;
|
||||
}
|
||||
if (payload.severity !== undefined) {
|
||||
updateData.severity_display = payload.severity;
|
||||
}
|
||||
if (payload.notes !== undefined) {
|
||||
updateData.note = payload.notes;
|
||||
}
|
||||
if (payload.diagnosisType !== undefined) {
|
||||
updateData.category = payload.diagnosisType === 'primary' ? 'primary-diagnosis' : 'encounter-diagnosis';
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('conditions')
|
||||
.update(updateData)
|
||||
.eq('id', diagnosisId);
|
||||
|
||||
if (error) {
|
||||
console.error('updateDiagnosis error', error);
|
||||
return { success: false, error: 'Diagnose bijwerken mislukt' };
|
||||
}
|
||||
|
||||
// Revalidate paths met correcte patientId en intakeId
|
||||
const patientId = diagnosis.patient_id;
|
||||
const intakeId = diagnosis.encounter_id;
|
||||
if (patientId && intakeId) {
|
||||
revalidatePath(buildPath(patientId, intakeId, 'diagnosis'));
|
||||
revalidatePath(buildPath(patientId, intakeId));
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function deleteDiagnosis(patientId: string, intakeId: string, diagnosisId: string) {
|
||||
const supabase = await getSupabase();
|
||||
const { error } = await supabase.from('conditions').delete().eq('id', diagnosisId);
|
||||
if (error) {
|
||||
console.error('deleteDiagnosis error', error);
|
||||
throw new Error(error.message);
|
||||
throw new Error('Diagnose verwijderen mislukt');
|
||||
}
|
||||
revalidatePath(buildPath(patientId, intakeId, 'diagnosis'));
|
||||
revalidatePath(buildPath(patientId, intakeId));
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Diagnose Card Component
|
||||
*
|
||||
* Visuele weergave van een diagnose met badges, expand/collapse en acties.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import { nl } from 'date-fns/locale';
|
||||
import { ChevronDown, ChevronUp, MoreVertical, Pencil, Trash2, Loader2 } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { type Condition } from '../../actions';
|
||||
|
||||
interface DiagnosisCardProps {
|
||||
diagnosis: Condition;
|
||||
isPrimary?: boolean;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
isDeleting?: boolean;
|
||||
}
|
||||
|
||||
// Status badge configuratie
|
||||
const STATUS_CONFIG: Record<string, { label: string; color: string; bgColor: string }> = {
|
||||
active: {
|
||||
label: 'Actief',
|
||||
color: 'text-green-700',
|
||||
bgColor: 'bg-green-100 border-green-300',
|
||||
},
|
||||
remission: {
|
||||
label: 'In remissie',
|
||||
color: 'text-blue-700',
|
||||
bgColor: 'bg-blue-100 border-blue-300',
|
||||
},
|
||||
resolved: {
|
||||
label: 'Opgelost',
|
||||
color: 'text-slate-700',
|
||||
bgColor: 'bg-slate-100 border-slate-300',
|
||||
},
|
||||
'entered-in-error': {
|
||||
label: 'Foutief',
|
||||
color: 'text-red-700',
|
||||
bgColor: 'bg-red-100 border-red-300',
|
||||
},
|
||||
};
|
||||
|
||||
// Severity labels
|
||||
const SEVERITY_LABELS: Record<string, string> = {
|
||||
licht: 'Licht',
|
||||
matig: 'Matig',
|
||||
ernstig: 'Ernstig',
|
||||
};
|
||||
|
||||
export function DiagnosisCard({
|
||||
diagnosis,
|
||||
isPrimary = false,
|
||||
onEdit,
|
||||
onDelete,
|
||||
isDeleting = false,
|
||||
}: DiagnosisCardProps) {
|
||||
const [isNotesExpanded, setIsNotesExpanded] = useState(false);
|
||||
|
||||
const code = diagnosis.code_code || '';
|
||||
const description = diagnosis.code_display || '';
|
||||
const severity = diagnosis.severity_display || '';
|
||||
const status = diagnosis.clinical_status || 'active';
|
||||
const notes = diagnosis.note || '';
|
||||
const recordedDate = diagnosis.recorded_date
|
||||
? new Date(diagnosis.recorded_date)
|
||||
: null;
|
||||
|
||||
const statusConfig = STATUS_CONFIG[status] || STATUS_CONFIG.active;
|
||||
const hasNotes = notes.trim().length > 0;
|
||||
|
||||
return (
|
||||
<Card className="transition-all hover:border-teal-300 hover:shadow-sm">
|
||||
<CardHeader className="p-4 pb-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
{/* Code + beschrijving */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-slate-900">
|
||||
{code && description ? (
|
||||
<>
|
||||
<span className="font-mono">{code}</span>
|
||||
{' — '}
|
||||
<span>{description}</span>
|
||||
</>
|
||||
) : (
|
||||
code || description || 'Geen diagnose code'
|
||||
)}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Badges en menu */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{/* HOOFD badge */}
|
||||
{isPrimary && (
|
||||
<Badge
|
||||
className="bg-green-600 text-white border-green-700 hover:bg-green-600"
|
||||
>
|
||||
HOOFD
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* Status badge */}
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-xs ${statusConfig.color} ${statusConfig.bgColor}`}
|
||||
>
|
||||
{statusConfig.label}
|
||||
</Badge>
|
||||
|
||||
{/* Context menu */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className="p-1 rounded-md hover:bg-slate-100 text-slate-500 hover:text-slate-700 transition-colors"
|
||||
disabled={isDeleting}
|
||||
aria-label="Meer opties"
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={onEdit} disabled={isDeleting}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Bewerken
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={onDelete}
|
||||
disabled={isDeleting}
|
||||
className="text-red-600 focus:text-red-600 focus:bg-red-50"
|
||||
>
|
||||
{isDeleting ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Verwijderen
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-4 pt-0 space-y-3">
|
||||
{/* Meta informatie */}
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm text-slate-600">
|
||||
{severity && (
|
||||
<div>
|
||||
<span className="font-medium">Ernst:</span>{' '}
|
||||
<span>{SEVERITY_LABELS[severity] || severity}</span>
|
||||
</div>
|
||||
)}
|
||||
{recordedDate && (
|
||||
<div>
|
||||
<span className="font-medium">Datum:</span>{' '}
|
||||
<span>{format(recordedDate, 'd MMM yyyy', { locale: nl })}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Onderbouwing (expand/collapse) */}
|
||||
{hasNotes && (
|
||||
<div className="border-t border-slate-100 pt-3">
|
||||
<button
|
||||
onClick={() => setIsNotesExpanded(!isNotesExpanded)}
|
||||
className="flex items-center gap-2 w-full text-left text-sm font-medium text-slate-700 hover:text-slate-900 transition-colors"
|
||||
>
|
||||
{isNotesExpanded ? (
|
||||
<ChevronUp className="h-4 w-4 text-slate-500" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-slate-500" />
|
||||
)}
|
||||
<span>Onderbouwing</span>
|
||||
</button>
|
||||
{isNotesExpanded && (
|
||||
<div className="mt-2 pl-6 text-sm text-slate-600 whitespace-pre-line">
|
||||
{notes}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
import { createDiagnosis, deleteDiagnosis, type Condition } from '../../actions';
|
||||
import { Loader2, Trash2 } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { nl } from 'date-fns/locale';
|
||||
/**
|
||||
* Diagnosis Manager Component
|
||||
*
|
||||
* Beheert de lijst van diagnoses met modal voor toevoegen/bewerken en cards voor weergave.
|
||||
*/
|
||||
|
||||
const severities = ['licht', 'matig', 'ernstig'];
|
||||
const statuses = ['active', 'resolved', 'entered-in-error'];
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useTransition } from 'react';
|
||||
import { Plus, Loader2, AlertTriangle } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { DiagnosisCard } from './diagnosis-card';
|
||||
import { DiagnosisModal } from './diagnosis-modal';
|
||||
import { deleteDiagnosis, type Condition } from '../../actions';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
|
||||
interface DiagnosisManagerProps {
|
||||
patientId: string;
|
||||
@@ -16,136 +30,180 @@ interface DiagnosisManagerProps {
|
||||
}
|
||||
|
||||
export function DiagnosisManager({ patientId, intakeId, diagnoses }: DiagnosisManagerProps) {
|
||||
const [form, setForm] = useState({ code: '', description: '', severity: severities[0], status: statuses[0], notes: '' });
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingDiagnosis, setEditingDiagnosis] = useState<Condition | undefined>();
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!form.code || !form.description) {
|
||||
setError('Code en omschrijving zijn verplicht.');
|
||||
return;
|
||||
}
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await createDiagnosis({
|
||||
patientId,
|
||||
intakeId,
|
||||
code: form.code,
|
||||
description: form.description,
|
||||
severity: form.severity,
|
||||
status: form.status,
|
||||
notes: form.notes,
|
||||
});
|
||||
setForm({ ...form, notes: '' });
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Opslaan mislukt');
|
||||
}
|
||||
// Sorteer diagnoses: hoofddiagnoses eerst, dan op datum
|
||||
const sortedDiagnoses = useMemo(() => {
|
||||
return [...diagnoses].sort((a, b) => {
|
||||
// Hoofddiagnoses eerst
|
||||
const aIsPrimary = a.category === 'primary-diagnosis';
|
||||
const bIsPrimary = b.category === 'primary-diagnosis';
|
||||
if (aIsPrimary && !bIsPrimary) return -1;
|
||||
if (!aIsPrimary && bIsPrimary) return 1;
|
||||
|
||||
// Dan op datum (nieuwste eerst)
|
||||
const aDate = a.recorded_date ? new Date(a.recorded_date).getTime() : 0;
|
||||
const bDate = b.recorded_date ? new Date(b.recorded_date).getTime() : 0;
|
||||
return bDate - aDate;
|
||||
});
|
||||
}, [diagnoses]);
|
||||
|
||||
const handleNewDiagnosis = () => {
|
||||
setEditingDiagnosis(undefined);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
setDeletingId(id);
|
||||
const handleEditDiagnosis = (diagnosis: Condition) => {
|
||||
setEditingDiagnosis(diagnosis);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteClick = (diagnosisId: string) => {
|
||||
setDeletingId(diagnosisId);
|
||||
setShowDeleteConfirm(true);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = () => {
|
||||
if (!deletingId) return;
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await deleteDiagnosis(patientId, intakeId, id);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Verwijderen mislukt');
|
||||
} finally {
|
||||
await deleteDiagnosis(patientId, intakeId, deletingId);
|
||||
toast({
|
||||
title: 'Diagnose verwijderd',
|
||||
description: 'De diagnose is succesvol verwijderd.',
|
||||
});
|
||||
setShowDeleteConfirm(false);
|
||||
setDeletingId(null);
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Verwijderen mislukt',
|
||||
description: error instanceof Error ? error.message : 'Er ging iets mis bij het verwijderen.',
|
||||
});
|
||||
setDeletingId(null);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleModalSuccess = () => {
|
||||
setModalOpen(false);
|
||||
setEditingDiagnosis(undefined);
|
||||
// Toast wordt al getoond door de modal
|
||||
};
|
||||
|
||||
const diagnosisToDelete = deletingId ? diagnoses.find((d) => d.id === deletingId) : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{diagnoses.length === 0 && <p className="text-sm text-slate-500">Nog geen diagnoses.</p>}
|
||||
{diagnoses.map((diag) => (
|
||||
<div key={diag.id} className="rounded-lg border border-slate-200 p-4 space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">{diag.code_code} — {diag.code_display}</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{diag.recorded_date
|
||||
? format(new Date(diag.recorded_date), 'd MMM yyyy', { locale: nl })
|
||||
: 'Onbekende datum'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(diag.id)}
|
||||
disabled={deletingId === diag.id}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-red-200 px-3 py-1.5 text-xs text-red-600"
|
||||
>
|
||||
{deletingId === diag.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />} Verwijder
|
||||
</button>
|
||||
</div>
|
||||
{diag.note && <p className="text-sm text-slate-700">{diag.note}</p>}
|
||||
</div>
|
||||
))}
|
||||
{/* Header met button */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-slate-900">
|
||||
Diagnoses ({diagnoses.length})
|
||||
</h3>
|
||||
</div>
|
||||
<Button onClick={handleNewDiagnosis} size="sm">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Nieuwe diagnose
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-slate-200 p-4 space-y-3">
|
||||
<h3 className="text-sm font-semibold text-slate-900">Nieuwe diagnose</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="DSM code"
|
||||
value={form.code}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, code: e.target.value }))}
|
||||
className="h-10 rounded-md border border-slate-300 px-3 text-sm"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Omschrijving"
|
||||
value={form.description}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, description: e.target.value }))}
|
||||
className="h-10 rounded-md border border-slate-300 px-3 text-sm"
|
||||
/>
|
||||
{/* Diagnoses lijst */}
|
||||
{sortedDiagnoses.length === 0 ? (
|
||||
<div className="text-center py-8 text-slate-500">
|
||||
<p className="text-sm">Nog geen diagnoses geregistreerd.</p>
|
||||
<p className="text-xs mt-1">Klik op "Nieuwe diagnose" om er een toe te voegen.</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<select
|
||||
value={form.severity}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, severity: e.target.value }))}
|
||||
className="h-10 rounded-md border border-slate-300 px-3 text-sm"
|
||||
>
|
||||
{severities.map((sev) => (
|
||||
<option key={sev} value={sev}>
|
||||
{sev}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={form.status}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, status: e.target.value }))}
|
||||
className="h-10 rounded-md border border-slate-300 px-3 text-sm"
|
||||
>
|
||||
{statuses.map((status) => (
|
||||
<option key={status} value={status}>
|
||||
{status}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{sortedDiagnoses.map((diagnosis) => {
|
||||
const isPrimary = diagnosis.category === 'primary-diagnosis';
|
||||
return (
|
||||
<DiagnosisCard
|
||||
key={diagnosis.id}
|
||||
diagnosis={diagnosis}
|
||||
isPrimary={isPrimary}
|
||||
onEdit={() => handleEditDiagnosis(diagnosis)}
|
||||
onDelete={() => handleDeleteClick(diagnosis.id)}
|
||||
isDeleting={deletingId === diagnosis.id && isPending}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<textarea
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, notes: e.target.value }))}
|
||||
placeholder="Notities"
|
||||
className="w-full rounded-md border border-slate-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={isPending}
|
||||
className="inline-flex items-center gap-2 rounded-md bg-teal-600 px-4 py-2 text-sm font-medium text-white"
|
||||
>
|
||||
{isPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
Opslaan
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Diagnosis Modal */}
|
||||
<DiagnosisModal
|
||||
open={modalOpen}
|
||||
onOpenChange={setModalOpen}
|
||||
patientId={patientId}
|
||||
intakeId={intakeId}
|
||||
diagnosis={editingDiagnosis}
|
||||
onSuccess={handleModalSuccess}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-red-600">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
Diagnose verwijderen?
|
||||
</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className="space-y-4 pt-2">
|
||||
<p>
|
||||
Weet je zeker dat je deze diagnose wilt verwijderen? Deze actie kan
|
||||
niet ongedaan worden gemaakt.
|
||||
</p>
|
||||
{diagnosisToDelete && (
|
||||
<div className="bg-red-50 rounded-lg p-4 border border-red-100">
|
||||
<div className="font-medium text-slate-900">
|
||||
{diagnosisToDelete.code_code} — {diagnosisToDelete.code_display}
|
||||
</div>
|
||||
{diagnosisToDelete.severity_display && (
|
||||
<div className="text-sm text-slate-600 mt-1">
|
||||
Ernst: {diagnosisToDelete.severity_display}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowDeleteConfirm(false);
|
||||
setDeletingId(null);
|
||||
}}
|
||||
disabled={isPending}
|
||||
>
|
||||
Annuleren
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteConfirm}
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Verwijderen...
|
||||
</>
|
||||
) : (
|
||||
'Verwijderen'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Diagnose Modal Component
|
||||
*
|
||||
* Modal voor toevoegen en bewerken van diagnoses met ICD-10 classificatie.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { ICD10Combobox } from './icd10-combobox';
|
||||
import {
|
||||
diagnosisSchema,
|
||||
diagnosisDefaults,
|
||||
type DiagnosisFormData,
|
||||
DIAGNOSIS_SEVERITIES,
|
||||
DIAGNOSIS_TYPES,
|
||||
DIAGNOSIS_STATUSES,
|
||||
} from '@/lib/schemas/diagnosis';
|
||||
import { createDiagnosis, updateDiagnosis, type Condition } from '../../actions';
|
||||
import type { ICD10Code } from '@/lib/types/icd10';
|
||||
|
||||
interface DiagnosisModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
patientId: string;
|
||||
intakeId: string;
|
||||
diagnosis?: Condition; // undefined = nieuw, Condition = bewerk
|
||||
onSuccess: () => void; // Callback na succesvol opslaan
|
||||
}
|
||||
|
||||
// Status labels voor weergave
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
active: 'Actief',
|
||||
remission: 'In remissie',
|
||||
resolved: 'Opgelost',
|
||||
inactive: 'Inactief',
|
||||
};
|
||||
|
||||
// Severity labels voor weergave
|
||||
const SEVERITY_LABELS: Record<string, string> = {
|
||||
licht: 'Licht',
|
||||
matig: 'Matig',
|
||||
ernstig: 'Ernstig',
|
||||
};
|
||||
|
||||
// Diagnose type labels
|
||||
const DIAGNOSIS_TYPE_LABELS: Record<string, string> = {
|
||||
primary: 'Hoofddiagnose',
|
||||
secondary: 'Nevendiagnose',
|
||||
};
|
||||
|
||||
export function DiagnosisModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
patientId,
|
||||
intakeId,
|
||||
diagnosis,
|
||||
onSuccess,
|
||||
}: DiagnosisModalProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [selectedICD10Code, setSelectedICD10Code] = useState<ICD10Code | null>(null);
|
||||
|
||||
const isEditMode = !!diagnosis;
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
reset,
|
||||
setValue,
|
||||
watch,
|
||||
} = useForm<DiagnosisFormData>({
|
||||
resolver: zodResolver(diagnosisSchema),
|
||||
defaultValues: diagnosisDefaults,
|
||||
});
|
||||
|
||||
const diagnosisType = watch('diagnosisType');
|
||||
const severity = watch('severity');
|
||||
const status = watch('status');
|
||||
|
||||
// Reset form wanneer modal opent/sluit of diagnosis wijzigt
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (diagnosis) {
|
||||
// Bewerk modus: vul form met bestaande data
|
||||
setValue('code', diagnosis.code_code || '');
|
||||
setValue('description', diagnosis.code_display || '');
|
||||
setValue('severity', (diagnosis.severity_display as 'licht' | 'matig' | 'ernstig') || 'matig');
|
||||
setValue('status', (diagnosis.clinical_status as 'active' | 'remission' | 'resolved' | 'inactive') || 'active');
|
||||
setValue('diagnosisType', diagnosis.category === 'primary-diagnosis' ? 'primary' : 'secondary');
|
||||
setValue('dsm5Reference', diagnosis.code_system === 'DSM-5' ? diagnosis.code_code || '' : '');
|
||||
setValue('notes', diagnosis.note || '');
|
||||
|
||||
// Set selected ICD-10 code voor combobox
|
||||
if (diagnosis.code_code && diagnosis.code_display) {
|
||||
setSelectedICD10Code({
|
||||
code: diagnosis.code_code,
|
||||
display: diagnosis.code_display,
|
||||
keywords: [],
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Nieuw: reset naar defaults
|
||||
reset(diagnosisDefaults);
|
||||
setSelectedICD10Code(null);
|
||||
}
|
||||
} else {
|
||||
// Modal gesloten: reset
|
||||
reset(diagnosisDefaults);
|
||||
setSelectedICD10Code(null);
|
||||
}
|
||||
}, [open, diagnosis, reset, setValue]);
|
||||
|
||||
const handleICD10Select = (code: ICD10Code) => {
|
||||
setSelectedICD10Code(code);
|
||||
setValue('code', code.code);
|
||||
setValue('description', code.display);
|
||||
};
|
||||
|
||||
const onSubmit = async (data: DiagnosisFormData) => {
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
if (isEditMode && diagnosis) {
|
||||
// Update bestaande diagnose
|
||||
const result = await updateDiagnosis(diagnosis.id, {
|
||||
code: data.code,
|
||||
description: data.description,
|
||||
severity: data.severity,
|
||||
status: data.status,
|
||||
notes: data.notes || undefined,
|
||||
diagnosisType: data.diagnosisType,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Bijwerken mislukt',
|
||||
description: result.error || 'Er ging iets mis bij het bijwerken.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Diagnose bijgewerkt',
|
||||
description: `${data.code} — ${data.description} is aangepast.`,
|
||||
});
|
||||
} else {
|
||||
// Create nieuwe diagnose
|
||||
await createDiagnosis({
|
||||
patientId,
|
||||
intakeId,
|
||||
code: data.code,
|
||||
description: data.description,
|
||||
severity: data.severity,
|
||||
status: data.status,
|
||||
notes: data.notes || undefined,
|
||||
diagnosisType: data.diagnosisType,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: 'Diagnose opgeslagen',
|
||||
description: `${data.code} — ${data.description} is toegevoegd.`,
|
||||
});
|
||||
}
|
||||
|
||||
onSuccess();
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Opslaan mislukt',
|
||||
description: error instanceof Error ? error.message : 'Er ging iets mis bij het opslaan.',
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEditMode ? 'Diagnose bewerken' : 'Nieuwe diagnose'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
{/* ICD-10 Code */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="icd10-code">
|
||||
ICD-10 Code <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<ICD10Combobox
|
||||
value={selectedICD10Code?.code || ''}
|
||||
onSelect={handleICD10Select}
|
||||
placeholder="Zoek op code of beschrijving..."
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
{errors.code && (
|
||||
<p className="text-sm text-red-600">{errors.code.message}</p>
|
||||
)}
|
||||
{errors.description && (
|
||||
<p className="text-sm text-red-600">{errors.description.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Ernst en Diagnose type */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="severity">
|
||||
Ernst <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={severity}
|
||||
onValueChange={(value) => setValue('severity', value as 'licht' | 'matig' | 'ernstig')}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger id="severity">
|
||||
<SelectValue placeholder="Selecteer ernst" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DIAGNOSIS_SEVERITIES.map((sev) => (
|
||||
<SelectItem key={sev} value={sev}>
|
||||
{SEVERITY_LABELS[sev]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.severity && (
|
||||
<p className="text-sm text-red-600">{errors.severity.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
Diagnose type <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<div className="flex gap-4">
|
||||
{DIAGNOSIS_TYPES.map((type) => (
|
||||
<div key={type} className="flex items-center space-x-2">
|
||||
<input
|
||||
type="radio"
|
||||
id={`diagnosis-type-${type}`}
|
||||
value={type}
|
||||
checked={diagnosisType === type}
|
||||
onChange={(e) => setValue('diagnosisType', e.target.value as 'primary' | 'secondary')}
|
||||
disabled={isSubmitting}
|
||||
className="h-4 w-4 text-teal-600 focus:ring-teal-500"
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`diagnosis-type-${type}`}
|
||||
className="font-normal cursor-pointer"
|
||||
>
|
||||
{DIAGNOSIS_TYPE_LABELS[type]}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{errors.diagnosisType && (
|
||||
<p className="text-sm text-red-600">{errors.diagnosisType.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="status">
|
||||
Status <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={status}
|
||||
onValueChange={(value) => setValue('status', value as 'active' | 'remission' | 'resolved' | 'inactive')}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger id="status">
|
||||
<SelectValue placeholder="Selecteer status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DIAGNOSIS_STATUSES.map((stat) => (
|
||||
<SelectItem key={stat} value={stat}>
|
||||
{STATUS_LABELS[stat]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.status && (
|
||||
<p className="text-sm text-red-600">{errors.status.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* DSM-5 referentie */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dsm5-reference">
|
||||
DSM-5 referentie (optioneel)
|
||||
</Label>
|
||||
<Input
|
||||
id="dsm5-reference"
|
||||
{...register('dsm5Reference')}
|
||||
placeholder="bijv. Major Depressive Disorder"
|
||||
disabled={isSubmitting}
|
||||
maxLength={100}
|
||||
/>
|
||||
{errors.dsm5Reference && (
|
||||
<p className="text-sm text-red-600">{errors.dsm5Reference.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Onderbouwing */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="notes">
|
||||
Onderbouwing (optioneel)
|
||||
</Label>
|
||||
<Textarea
|
||||
id="notes"
|
||||
{...register('notes')}
|
||||
placeholder="Klinische redenering..."
|
||||
rows={4}
|
||||
disabled={isSubmitting}
|
||||
maxLength={500}
|
||||
/>
|
||||
<p className="text-xs text-slate-500">
|
||||
{watch('notes')?.length || 0} / 500 karakters
|
||||
</p>
|
||||
{errors.notes && (
|
||||
<p className="text-sm text-red-600">{errors.notes.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Annuleren
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{isSubmitting ? 'Opslaan...' : 'Opslaan'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { Check, ChevronsUpDown } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import icd10CodesData from '@/lib/data/icd10-ggz-codes.json';
|
||||
import {
|
||||
type ICD10Code,
|
||||
type FlatICD10Code,
|
||||
flattenICD10Codes,
|
||||
searchICD10Codes,
|
||||
getFrequentCodes,
|
||||
type ICD10CodeList,
|
||||
} from '@/lib/types/icd10';
|
||||
|
||||
interface ICD10ComboboxProps {
|
||||
value: string;
|
||||
onSelect: (code: ICD10Code) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// Debounce hook
|
||||
function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
|
||||
export function ICD10Combobox({
|
||||
value,
|
||||
onSelect,
|
||||
placeholder = 'Zoek ICD-10 code...',
|
||||
disabled = false,
|
||||
}: ICD10ComboboxProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// Flatten ICD-10 codes once
|
||||
const flatCodes = useMemo(() => {
|
||||
return flattenICD10Codes(icd10CodesData as ICD10CodeList);
|
||||
}, []);
|
||||
|
||||
// Debounce search query (200ms)
|
||||
const debouncedQuery = useDebounce(searchQuery, 200);
|
||||
|
||||
// Get search results or frequent codes
|
||||
const displayCodes = useMemo(() => {
|
||||
if (debouncedQuery.trim()) {
|
||||
// Search mode: max 8 results
|
||||
return searchICD10Codes(flatCodes, debouncedQuery, 8);
|
||||
} else {
|
||||
// Empty field: show top 5 frequent codes
|
||||
return getFrequentCodes(flatCodes, icd10CodesData.frequentCodes).slice(0, 5);
|
||||
}
|
||||
}, [debouncedQuery, flatCodes]);
|
||||
|
||||
// Find selected code object
|
||||
const selectedCode = useMemo(() => {
|
||||
if (!value) return null;
|
||||
return flatCodes.find((code) => code.code === value) || null;
|
||||
}, [value, flatCodes]);
|
||||
|
||||
const handleSelect = (code: FlatICD10Code) => {
|
||||
onSelect({
|
||||
code: code.code,
|
||||
display: code.display,
|
||||
keywords: code.keywords,
|
||||
});
|
||||
setOpen(false);
|
||||
setSearchQuery('');
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-full justify-between"
|
||||
disabled={disabled}
|
||||
>
|
||||
{selectedCode ? (
|
||||
<span className="truncate">
|
||||
{selectedCode.code} — {selectedCode.display}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">{placeholder}</span>
|
||||
)}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[400px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Zoek op code of beschrijving..."
|
||||
value={searchQuery}
|
||||
onValueChange={setSearchQuery}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{debouncedQuery.trim()
|
||||
? 'Geen codes gevonden.'
|
||||
: 'Begin met typen om te zoeken...'}
|
||||
</CommandEmpty>
|
||||
{!debouncedQuery.trim() && (
|
||||
<CommandGroup heading="Veelgebruikte codes">
|
||||
{displayCodes.map((code) => (
|
||||
<CommandItem
|
||||
key={code.code}
|
||||
value={code.code}
|
||||
onSelect={() => handleSelect(code)}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4',
|
||||
selectedCode?.code === code.code
|
||||
? 'opacity-100'
|
||||
: 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{code.code}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{code.display}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{debouncedQuery.trim() && (
|
||||
<CommandGroup heading="Zoekresultaten">
|
||||
{displayCodes.map((code) => (
|
||||
<CommandItem
|
||||
key={code.code}
|
||||
value={code.code}
|
||||
onSelect={() => handleSelect(code)}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4',
|
||||
selectedCode?.code === code.code
|
||||
? 'opacity-100'
|
||||
: 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{code.code}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{code.display}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground italic">
|
||||
{code.category}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export default async function IntakeDiagnosisPage({
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">Diagnoses</h2>
|
||||
<p className="text-sm text-slate-600">
|
||||
Registreer DSM-5 diagnoses gekoppeld aan deze intake.
|
||||
Registreer diagnoses met ICD-10 classificatie gekoppeld aan deze intake.
|
||||
</p>
|
||||
</div>
|
||||
<DiagnosisManager patientId={id} intakeId={intakeId} diagnoses={diagnoses} />
|
||||
|
||||
@@ -11,11 +11,21 @@ import {
|
||||
ArrowRight,
|
||||
AlertCircle,
|
||||
Calendar,
|
||||
Clock,
|
||||
} from 'lucide-react';
|
||||
import { getIntakesByPatientId } from './intakes/actions';
|
||||
import type { Intake } from '@/lib/types/intake';
|
||||
import { format } from 'date-fns';
|
||||
import { nl } from 'date-fns/locale';
|
||||
import { getPatientEncounters } from '@/app/epd/agenda/actions';
|
||||
import { getActiveCarePlan, getPatientIntakes } 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({
|
||||
params,
|
||||
@@ -34,6 +44,45 @@ export default async function PatientDashboardPage({
|
||||
console.error('Failed to fetch intakes for dashboard:', error);
|
||||
}
|
||||
|
||||
// Fetch encounters (vandaag, toekomst en recente)
|
||||
let upcomingEncounters: any[] = [];
|
||||
let recentEncounters: any[] = [];
|
||||
try {
|
||||
const allEncounters = await getPatientEncounters(id);
|
||||
const now = new Date();
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
|
||||
// Split into upcoming (vandaag + toekomst) and recent (verleden)
|
||||
const upcoming = allEncounters.filter(e => new Date(e.period_start) >= todayStart);
|
||||
const recent = allEncounters.filter(e => new Date(e.period_start) < todayStart);
|
||||
|
||||
// Take 5 most relevant: prioritize upcoming, then recent
|
||||
upcomingEncounters = upcoming.slice(0, 5);
|
||||
if (upcomingEncounters.length < 5) {
|
||||
recentEncounters = recent.slice(0, 5 - upcomingEncounters.length);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch encounters:', error);
|
||||
}
|
||||
|
||||
// Fetch active care plan
|
||||
let activeCarePlan: any = null;
|
||||
let hulpvraag: string | null = null;
|
||||
try {
|
||||
activeCarePlan = await getActiveCarePlan(id);
|
||||
|
||||
// Get hulpvraag from linked intake
|
||||
if (activeCarePlan?.based_on_intake_id) {
|
||||
const intakes = await getPatientIntakes(id);
|
||||
const linkedIntake = intakes.find(i => i.id === activeCarePlan.based_on_intake_id);
|
||||
if (linkedIntake?.notes) {
|
||||
hulpvraag = extractHulpvraag(linkedIntake.notes);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch care plan:', error);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
{/* Page Header */}
|
||||
@@ -160,6 +209,219 @@ export default async function PatientDashboardPage({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Agenda Afspraken Section */}
|
||||
{(upcomingEncounters.length > 0 || recentEncounters.length > 0) && (
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-slate-900">Agenda Afspraken</h3>
|
||||
<Link
|
||||
href={`/epd/agenda?encounterId=${upcomingEncounters[0]?.id || recentEncounters[0]?.id}`}
|
||||
className="text-sm text-teal-600 hover:text-teal-700 font-medium"
|
||||
>
|
||||
Bekijk agenda →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{[...upcomingEncounters, ...recentEncounters].slice(0, 5).map((encounter) => {
|
||||
const encounterDate = new Date(encounter.period_start);
|
||||
const isPast = encounterDate < new Date();
|
||||
const isToday = encounterDate.toDateString() === new Date().toDateString();
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={encounter.id}
|
||||
href={`/epd/agenda?encounterId=${encounter.id}`}
|
||||
className="block p-3 rounded-lg border border-slate-200 hover:border-teal-300 hover:bg-teal-50 transition-colors group"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-8 h-8 rounded-md flex items-center justify-center ${
|
||||
isPast ? 'bg-slate-100' : isToday ? 'bg-blue-50' : 'bg-teal-50'
|
||||
} group-hover:bg-teal-100 transition-colors`}>
|
||||
<Calendar className={`h-4 w-4 ${
|
||||
isPast ? 'text-slate-500' : isToday ? 'text-blue-600' : 'text-teal-600'
|
||||
}`} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-slate-900 group-hover:text-teal-700">
|
||||
{encounter.type_display || 'Afspraak'}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500 mt-0.5">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>
|
||||
{format(encounterDate, 'd MMM yyyy HH:mm', { locale: nl })}
|
||||
</span>
|
||||
{encounter.period_end && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>
|
||||
{format(new Date(encounter.period_end), 'HH:mm', { locale: nl })}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
encounter.status === 'planned' || encounter.status === 'arrived'
|
||||
? 'bg-blue-50 text-blue-700'
|
||||
: encounter.status === 'finished'
|
||||
? 'bg-green-50 text-green-700'
|
||||
: 'bg-slate-50 text-slate-700'
|
||||
}`}>
|
||||
{encounter.status === 'planned' ? 'Gepland' :
|
||||
encounter.status === 'arrived' ? 'Aangekomen' :
|
||||
encounter.status === 'finished' ? 'Afgerond' : encounter.status}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</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">
|
||||
<div>
|
||||
<span className="font-medium">Duur:</span> {activeCarePlan.behandelstructuur.duur}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Frequentie:</span> {activeCarePlan.behandelstructuur.frequentie}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Aantal sessies:</span> {activeCarePlan.behandelstructuur.aantalSessies}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Vorm:</span> {activeCarePlan.behandelstructuur.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.slice(0, 3).map((goal: SmartGoal) => (
|
||||
<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.slice(0, 5).map((intervention: 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
|
||||
.filter((evaluatie: Evaluatiemoment) => evaluatie.status === 'gepland')
|
||||
.slice(0, 2)
|
||||
.map((evaluatie: Evaluatiemoment) => (
|
||||
<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 */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
|
||||
Reference in New Issue
Block a user