feat(agenda): Epic 4 - EPD Koppeling (appointment-report integration)
Implements bidirectional linking between appointments and reports: - E4.S1: Create report from appointment modal with encounter pre-linked - E4.S2: Link existing reports to appointments via EncounterSelector - E4.S3: Show linked reports in appointment modal edit view - E4.S4: Navigation between appointments and reports with deep linking Also includes bug fixes for patient search: - Fix FHIR to internal format mapping for patient data - Fix debounced search interference after patient selection - Fix UUID handling for optional practitioner_id 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -58,7 +58,7 @@ export async function createReport(
|
||||
export async function updateReport(
|
||||
patientId: string,
|
||||
reportId: string,
|
||||
input: { content: string }
|
||||
input: { content?: string; encounter_id?: string | null }
|
||||
): Promise<Report> {
|
||||
const baseUrl = getBaseUrl();
|
||||
const url = `${baseUrl}/api/reports/${reportId}`;
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import { nl } from 'date-fns/locale';
|
||||
import { Calendar, Link2, Link2Off, ChevronDown, Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getPatientEncounters } from '@/app/epd/agenda/actions';
|
||||
|
||||
interface Encounter {
|
||||
id: string;
|
||||
period_start: string;
|
||||
period_end: string | null;
|
||||
type_code: string;
|
||||
type_display: string;
|
||||
status: string;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
interface EncounterSelectorProps {
|
||||
patientId: string;
|
||||
value?: string | null;
|
||||
onChange: (encounterId: string | null) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
intake: 'Intake',
|
||||
behandeling: 'Behandeling',
|
||||
'follow-up': 'Follow-up',
|
||||
telefonisch: 'Telefonisch',
|
||||
huisbezoek: 'Huisbezoek',
|
||||
online: 'Online consult',
|
||||
crisis: 'Crisis',
|
||||
overig: 'Overig',
|
||||
};
|
||||
|
||||
export function EncounterSelector({
|
||||
patientId,
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
}: EncounterSelectorProps) {
|
||||
const [encounters, setEncounters] = useState<Encounter[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [hasLoaded, setHasLoaded] = useState(false);
|
||||
|
||||
// Load encounters when dropdown opens
|
||||
useEffect(() => {
|
||||
if (isOpen && !hasLoaded) {
|
||||
setIsLoading(true);
|
||||
getPatientEncounters(patientId)
|
||||
.then((data) => {
|
||||
setEncounters(data);
|
||||
setHasLoaded(true);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to load encounters:', error);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
}, [isOpen, hasLoaded, patientId]);
|
||||
|
||||
const selectedEncounter = encounters.find((e) => e.id === value);
|
||||
|
||||
const formatEncounterDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
return format(date, "d MMM yyyy 'om' HH:mm", { locale: nl });
|
||||
};
|
||||
|
||||
const handleSelect = (encounterId: string | null) => {
|
||||
onChange(encounterId);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'w-full flex items-center justify-between gap-2 px-3 py-2 text-left',
|
||||
'border rounded-lg text-sm transition-colors',
|
||||
disabled
|
||||
? 'bg-slate-100 text-slate-400 cursor-not-allowed'
|
||||
: 'bg-white hover:bg-slate-50',
|
||||
value
|
||||
? 'border-teal-300 bg-teal-50'
|
||||
: 'border-slate-200'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{value ? (
|
||||
<Link2 className="h-4 w-4 text-teal-600 shrink-0" />
|
||||
) : (
|
||||
<Calendar className="h-4 w-4 text-slate-400 shrink-0" />
|
||||
)}
|
||||
<span className={cn('truncate', value ? 'text-teal-700' : 'text-slate-500')}>
|
||||
{selectedEncounter
|
||||
? `${TYPE_LABELS[selectedEncounter.type_code] || selectedEncounter.type_display} - ${formatEncounterDate(selectedEncounter.period_start)}`
|
||||
: 'Koppel aan afspraak...'}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-4 w-4 shrink-0 transition-transform',
|
||||
isOpen && 'rotate-180',
|
||||
value ? 'text-teal-600' : 'text-slate-400'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Dropdown */}
|
||||
{isOpen && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Menu */}
|
||||
<div className="absolute z-50 w-full mt-1 bg-white border border-slate-200 rounded-lg shadow-lg max-h-64 overflow-auto">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-4 text-sm text-slate-500">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Afspraken laden...
|
||||
</div>
|
||||
) : encounters.length === 0 ? (
|
||||
<div className="py-4 px-3 text-sm text-slate-500 text-center">
|
||||
Geen afspraken gevonden
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Option to unlink */}
|
||||
{value && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSelect(null)}
|
||||
className="w-full px-3 py-2 text-left hover:bg-red-50 flex items-center gap-2 text-red-600 border-b border-slate-100"
|
||||
>
|
||||
<Link2Off className="h-4 w-4" />
|
||||
<span className="text-sm">Koppeling verwijderen</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Encounters list */}
|
||||
{encounters.map((encounter) => {
|
||||
const isSelected = encounter.id === value;
|
||||
return (
|
||||
<button
|
||||
key={encounter.id}
|
||||
type="button"
|
||||
onClick={() => handleSelect(encounter.id)}
|
||||
className={cn(
|
||||
'w-full px-3 py-2 text-left hover:bg-slate-50',
|
||||
isSelected && 'bg-teal-50'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm font-medium',
|
||||
isSelected ? 'text-teal-700' : 'text-slate-900'
|
||||
)}
|
||||
>
|
||||
{TYPE_LABELS[encounter.type_code] || encounter.type_display}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs px-1.5 py-0.5 rounded',
|
||||
encounter.status === 'completed'
|
||||
? 'bg-emerald-100 text-emerald-700'
|
||||
: encounter.status === 'planned'
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'bg-slate-100 text-slate-600'
|
||||
)}
|
||||
>
|
||||
{encounter.status === 'completed'
|
||||
? 'Afgerond'
|
||||
: encounter.status === 'planned'
|
||||
? 'Gepland'
|
||||
: encounter.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">
|
||||
{formatEncounterDate(encounter.period_start)}
|
||||
</div>
|
||||
{encounter.notes && (
|
||||
<div className="text-xs text-slate-400 mt-0.5 truncate">
|
||||
{encounter.notes}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { ChevronRight, ChevronLeft } from 'lucide-react'
|
||||
import { ChevronRight, ChevronLeft, Calendar } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { Report } from '@/lib/types/report'
|
||||
import { ReportComposer } from './report-composer'
|
||||
@@ -33,6 +33,7 @@ interface RapportageWorkspaceV2Props {
|
||||
patientId: string
|
||||
patientName: string
|
||||
initialReports: Report[]
|
||||
linkedEncounterId?: string
|
||||
}
|
||||
|
||||
type PanelsModule = typeof import('react-resizable-panels')
|
||||
@@ -45,6 +46,7 @@ export function RapportageWorkspaceV2({
|
||||
patientId,
|
||||
patientName,
|
||||
initialReports,
|
||||
linkedEncounterId,
|
||||
}: RapportageWorkspaceV2Props) {
|
||||
const [reports, setReports] = useState(initialReports)
|
||||
const [selectedType, setSelectedType] = useState<ReportType>('vrije_notitie')
|
||||
@@ -137,6 +139,13 @@ export function RapportageWorkspaceV2({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{linkedEncounterId && (
|
||||
<div className="mb-3 p-2 bg-teal-50 border border-teal-200 rounded-lg flex items-center gap-2 text-sm text-teal-700">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>Dit verslag wordt gekoppeld aan de afspraak</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<QuickActions onSelectType={handleTypeSelect} selectedType={selectedType} />
|
||||
</div>
|
||||
|
||||
@@ -150,6 +159,7 @@ export function RapportageWorkspaceV2({
|
||||
onReportCreated={handleReportCreated}
|
||||
initialContent={duplicateContent}
|
||||
onInitialContentConsumed={() => setDuplicateContent(null)}
|
||||
linkedEncounterId={linkedEncounterId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -43,6 +43,8 @@ interface ReportComposerProps {
|
||||
initialContent?: string | null;
|
||||
/** Callback wanneer initialContent is verwerkt */
|
||||
onInitialContentConsumed?: () => void;
|
||||
/** Linked encounter ID for linking report to appointment */
|
||||
linkedEncounterId?: string;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -58,6 +60,7 @@ export function ReportComposer({
|
||||
onReportCreated,
|
||||
initialContent,
|
||||
onInitialContentConsumed,
|
||||
linkedEncounterId,
|
||||
}: ReportComposerProps) {
|
||||
const router = useRouter();
|
||||
const [editorRef, setEditorRef] = useState<Editor | null>(null);
|
||||
@@ -221,6 +224,7 @@ export function ReportComposer({
|
||||
content: textContent, // Save plain text for now
|
||||
ai_confidence: classification?.confidence,
|
||||
ai_reasoning: classification?.reasoning,
|
||||
encounter_id: linkedEncounterId,
|
||||
});
|
||||
toast({
|
||||
title: 'Rapportage opgeslagen',
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { X, Pencil, Copy, Trash2, Save, Loader2 } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { X, Pencil, Copy, Trash2, Save, Loader2, Calendar, ExternalLink } from 'lucide-react'
|
||||
import { format, formatDistanceToNow } from 'date-fns'
|
||||
import { nl } from 'date-fns/locale'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -9,6 +10,17 @@ import type { Report } from '@/lib/types/report'
|
||||
import { SpeechRecorderStreaming } from '@/components/speech-recorder-streaming'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
import { updateReport, deleteReport } from '../actions'
|
||||
import { EncounterSelector } from './encounter-selector'
|
||||
import { getEncounterById } from '@/app/epd/agenda/actions'
|
||||
|
||||
interface LinkedEncounter {
|
||||
id: string
|
||||
period_start: string
|
||||
period_end: string | null
|
||||
type_code: string
|
||||
type_display: string
|
||||
status: string
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Types
|
||||
@@ -179,12 +191,15 @@ export function ReportViewEditModal({
|
||||
const [mode, setMode] = useState<ModalMode>('read')
|
||||
const [content, setContent] = useState('')
|
||||
const [originalContent, setOriginalContent] = useState('')
|
||||
const [encounterId, setEncounterId] = useState<string | null>(null)
|
||||
const [originalEncounterId, setOriginalEncounterId] = useState<string | null>(null)
|
||||
const [linkedEncounter, setLinkedEncounter] = useState<LinkedEncounter | null>(null)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [showUnsavedDialog, setShowUnsavedDialog] = useState(false)
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [isStreaming, setIsStreaming] = useState(false)
|
||||
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
// Sync content met report
|
||||
@@ -192,10 +207,23 @@ export function ReportViewEditModal({
|
||||
if (report) {
|
||||
setContent(report.content)
|
||||
setOriginalContent(report.content)
|
||||
setEncounterId(report.encounter_id || null)
|
||||
setOriginalEncounterId(report.encounter_id || null)
|
||||
setMode('read')
|
||||
}
|
||||
}, [report])
|
||||
|
||||
// Fetch linked encounter details for navigation
|
||||
useEffect(() => {
|
||||
if (encounterId) {
|
||||
getEncounterById(encounterId).then((encounter) => {
|
||||
setLinkedEncounter(encounter)
|
||||
})
|
||||
} else {
|
||||
setLinkedEncounter(null)
|
||||
}
|
||||
}, [encounterId])
|
||||
|
||||
// Reset bij sluiten
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
@@ -206,7 +234,7 @@ export function ReportViewEditModal({
|
||||
}, [isOpen])
|
||||
|
||||
// Check for unsaved changes
|
||||
const hasUnsavedChanges = content !== originalContent
|
||||
const hasUnsavedChanges = content !== originalContent || encounterId !== originalEncounterId
|
||||
|
||||
// Keyboard handler (Escape)
|
||||
// Handlers
|
||||
@@ -254,8 +282,12 @@ export function ReportViewEditModal({
|
||||
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const updated = await updateReport(patientId, report.id, { content })
|
||||
const updated = await updateReport(patientId, report.id, {
|
||||
content,
|
||||
encounter_id: encounterId,
|
||||
})
|
||||
setOriginalContent(content)
|
||||
setOriginalEncounterId(encounterId)
|
||||
onReportUpdated?.(updated)
|
||||
toast({
|
||||
title: 'Wijzigingen opgeslagen',
|
||||
@@ -271,7 +303,7 @@ export function ReportViewEditModal({
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}, [report, patientId, content, onReportUpdated])
|
||||
}, [report, patientId, content, encounterId, onReportUpdated])
|
||||
|
||||
const handleSaveAndClose = useCallback(async () => {
|
||||
await handleSave()
|
||||
@@ -281,9 +313,10 @@ export function ReportViewEditModal({
|
||||
|
||||
const handleDiscardAndClose = useCallback(() => {
|
||||
setContent(originalContent)
|
||||
setEncounterId(originalEncounterId)
|
||||
setShowUnsavedDialog(false)
|
||||
onClose()
|
||||
}, [originalContent, onClose])
|
||||
}, [originalContent, originalEncounterId, onClose])
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!report) return
|
||||
@@ -500,6 +533,58 @@ export function ReportViewEditModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Encounter linking */}
|
||||
<div className="px-6 pb-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Calendar className="h-4 w-4 text-slate-500" />
|
||||
<span className="text-sm font-medium text-slate-700">Gekoppelde afspraak</span>
|
||||
</div>
|
||||
|
||||
{mode === 'read' && linkedEncounter ? (
|
||||
// Read mode with linked encounter - show clickable card
|
||||
<Link
|
||||
href={`/epd/agenda?date=${format(new Date(linkedEncounter.period_start), 'yyyy-MM-dd')}&encounterId=${linkedEncounter.id}`}
|
||||
className="block p-3 bg-teal-50 hover:bg-teal-100 rounded-lg border border-teal-200 transition-colors group"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-teal-700">
|
||||
{linkedEncounter.type_display || linkedEncounter.type_code}
|
||||
</span>
|
||||
<ExternalLink className="h-4 w-4 text-teal-500 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
<div className="text-xs text-teal-600 mt-0.5">
|
||||
{format(new Date(linkedEncounter.period_start), "EEEE d MMMM yyyy 'om' HH:mm", { locale: nl })}
|
||||
</div>
|
||||
<div className="text-xs text-teal-500 mt-1 flex items-center gap-1">
|
||||
<span className={cn(
|
||||
'px-1.5 py-0.5 rounded text-xs',
|
||||
linkedEncounter.status === 'completed' ? 'bg-emerald-100 text-emerald-700' :
|
||||
linkedEncounter.status === 'planned' ? 'bg-blue-100 text-blue-700' :
|
||||
'bg-slate-100 text-slate-600'
|
||||
)}>
|
||||
{linkedEncounter.status === 'completed' ? 'Afgerond' :
|
||||
linkedEncounter.status === 'planned' ? 'Gepland' :
|
||||
linkedEncounter.status}
|
||||
</span>
|
||||
<span className="text-teal-400">•</span>
|
||||
<span>Klik om naar agenda te gaan</span>
|
||||
</div>
|
||||
</Link>
|
||||
) : mode === 'read' ? (
|
||||
// Read mode without linked encounter
|
||||
<div className="text-sm text-slate-400 py-2 italic">
|
||||
Geen afspraak gekoppeld. Bewerk om een afspraak te koppelen.
|
||||
</div>
|
||||
) : (
|
||||
// Edit mode - show selector
|
||||
<EncounterSelector
|
||||
patientId={patientId}
|
||||
value={encounterId}
|
||||
onChange={setEncounterId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer with metadata */}
|
||||
{report.ai_reasoning && (
|
||||
<div className="px-6 pb-6">
|
||||
|
||||
@@ -4,10 +4,13 @@ import { getPatient } from '../../actions';
|
||||
|
||||
export default async function RapportagePage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<{ encounterId?: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const { encounterId } = await searchParams;
|
||||
const [reports, patient] = await Promise.all([getReports(id), getPatient(id)]);
|
||||
const patientName = formatPatientName(patient);
|
||||
|
||||
@@ -16,6 +19,7 @@ export default async function RapportagePage({
|
||||
patientId={id}
|
||||
patientName={patientName}
|
||||
initialReports={reports}
|
||||
linkedEncounterId={encounterId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user