From 779ab85e5d45df9c1640822651ac79b14399e26f Mon Sep 17 00:00:00 2001 From: colinislit Date: Tue, 25 Nov 2025 14:46:03 +0100 Subject: [PATCH] feat: improve rapportage composer UX with TipTap editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI improvements based on UX review: - Replace textarea with TipTap rich text editor - Add mic button in editor toolbar (collapsible recorder) - Remove duplicate type selection (keep only quick action buttons) - Remove redundant "Nieuwe rapportage:" label - Add streaming highlight on editor border during recording - Extend RichTextEditor with toolbarExtra, isStreaming props πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../rapportage/components/quick-actions.tsx | 2 - .../components/rapportage-workspace-v2.tsx | 2 + .../components/rapportage-workspace.tsx | 2 + .../rapportage/components/report-composer.tsx | 281 +++++++++--------- components/rich-text-editor.tsx | 158 ++++++---- .../screenprint-nieuwe-rapportage-ux.png | Bin 0 -> 130275 bytes 6 files changed, 251 insertions(+), 194 deletions(-) create mode 100644 docs/troubleshooting/screenprint-nieuwe-rapportage-ux.png diff --git a/app/epd/patients/[id]/rapportage/components/quick-actions.tsx b/app/epd/patients/[id]/rapportage/components/quick-actions.tsx index b8aa262..ec3c364 100644 --- a/app/epd/patients/[id]/rapportage/components/quick-actions.tsx +++ b/app/epd/patients/[id]/rapportage/components/quick-actions.tsx @@ -52,8 +52,6 @@ export function QuickActions({ return (
- Nieuwe rapportage: - {QUICK_ACTIONS.map((action) => { const Icon = action.icon const isActive = selectedType === action.type diff --git a/app/epd/patients/[id]/rapportage/components/rapportage-workspace-v2.tsx b/app/epd/patients/[id]/rapportage/components/rapportage-workspace-v2.tsx index 51ba95e..75df31a 100644 --- a/app/epd/patients/[id]/rapportage/components/rapportage-workspace-v2.tsx +++ b/app/epd/patients/[id]/rapportage/components/rapportage-workspace-v2.tsx @@ -144,6 +144,8 @@ export function RapportageWorkspaceV2({ import('@/components/rich-text-editor').then((m) => m.RichTextEditor), + { ssr: false, loading: () => } +); const SpeechRecorderStreaming = dynamic( () => import('@/components/speech-recorder-streaming').then((m) => m.SpeechRecorderStreaming), { ssr: false, loading: () => } ); +function EditorSkeleton() { + return ( +
+
+
+
+ ); +} + function RecorderSkeleton() { return (
@@ -23,9 +40,17 @@ function RecorderSkeleton() { ); } +// ───────────────────────────────────────────────────────────────────────────── +// Types +// ───────────────────────────────────────────────────────────────────────────── + interface ReportComposerProps { patientId: string; patientName: string; + /** Het geselecteerde report type (van parent) */ + selectedType: ReportType; + /** Callback om type te wijzigen */ + onTypeChange?: (type: ReportType) => void; selectedReport?: Report | null; onReportCreated?: (report: Report) => void; /** Initial content voor de editor (bijv. van duplicate) */ @@ -34,64 +59,52 @@ interface ReportComposerProps { onInitialContentConsumed?: () => void; } +// ───────────────────────────────────────────────────────────────────────────── +// Component +// ───────────────────────────────────────────────────────────────────────────── + export function ReportComposer({ patientId, patientName, + selectedType, + onTypeChange, selectedReport, onReportCreated, initialContent, onInitialContentConsumed, }: ReportComposerProps) { const router = useRouter(); - const textareaRef = useRef(null); + const [editorRef, setEditorRef] = useState(null); const [content, setContent] = useState(''); const [classification, setClassification] = useState(null); - const [selectedType, setSelectedType] = useState<'behandeladvies' | 'vrije_notitie'>('vrije_notitie'); const [isAnalyzing, setIsAnalyzing] = useState(false); const [isSaving, setIsSaving] = useState(false); const [error, setError] = useState(null); const [lastAutosave, setLastAutosave] = useState(null); const [isStreaming, setIsStreaming] = useState(false); - const [interimText, setInterimText] = useState(''); + const [showRecorder, setShowRecorder] = useState(false); const draftStorageKey = useMemo(() => `rapportage-draft-${patientId}`, [patientId]); + // Calculate plain text length from HTML content + const textContent = useMemo(() => { + if (typeof document === 'undefined') return ''; + const div = document.createElement('div'); + div.innerHTML = content; + return div.textContent || div.innerText || ''; + }, [content]); + + const characterCount = textContent.length; + const contentInvalid = characterCount < 20 || characterCount > 5000; + // Handle initialContent (e.g., from duplicate) useEffect(() => { if (initialContent) { setContent(initialContent); onInitialContentConsumed?.(); - // Focus en scroll naar einde - setTimeout(() => { - if (textareaRef.current) { - textareaRef.current.focus(); - textareaRef.current.scrollTop = textareaRef.current.scrollHeight; - } - }, 100); } }, [initialContent, onInitialContentConsumed]); - // Cursor naar einde verplaatsen bij start opname - const handleRecordingStart = useCallback(() => { - setIsStreaming(true); - if (textareaRef.current) { - const textarea = textareaRef.current; - textarea.focus(); - // Verplaats cursor naar het einde - const length = textarea.value.length; - textarea.setSelectionRange(length, length); - // Scroll naar beneden - textarea.scrollTop = textarea.scrollHeight; - } - }, []); - - const handleRecordingStop = useCallback(() => { - setIsStreaming(false); - setInterimText(''); - }, []); - - const characterCount = content.length; - const contentInvalid = characterCount < 20 || characterCount > 5000; - + // Load draft from localStorage useEffect(() => { if (typeof window === 'undefined') return; const stored = window.localStorage.getItem(draftStorageKey); @@ -99,14 +112,14 @@ export function ReportComposer({ try { const draft = JSON.parse(stored) as { content?: string; - type?: 'behandeladvies' | 'vrije_notitie'; + type?: ReportType; updatedAt?: string; }; if (draft.content) { setContent(draft.content); } - if (draft.type) { - setSelectedType(draft.type); + if (draft.type && onTypeChange) { + onTypeChange(draft.type); } if (draft.updatedAt) { setLastAutosave(new Date(draft.updatedAt)); @@ -116,8 +129,9 @@ export function ReportComposer({ window.localStorage.removeItem(draftStorageKey); } } - }, [draftStorageKey]); + }, [draftStorageKey, onTypeChange]); + // Autosave draft useEffect(() => { if (typeof window === 'undefined') return; if (!content && selectedType === 'vrije_notitie') { @@ -139,6 +153,30 @@ export function ReportComposer({ return () => window.clearTimeout(timeout); }, [content, selectedType, draftStorageKey]); + // Recording handlers + const handleRecordingStart = useCallback(() => { + setIsStreaming(true); + if (editorRef) { + editorRef.chain().focus().run(); + } + }, [editorRef]); + + const handleRecordingStop = useCallback(() => { + setIsStreaming(false); + }, []); + + const handleTranscript = useCallback((text: string) => { + setContent((prev) => { + // If editor has content, append with space + const plainPrev = prev.replace(/<[^>]*>/g, '').trim(); + if (plainPrev) { + return `${prev}

${text}

`; + } + return `

${text}

`; + }); + }, []); + + // Reference snippet for context const referenceSnippet = useMemo(() => { if (!selectedReport) return null; const createdAt = selectedReport.created_at ? new Date(selectedReport.created_at) : null; @@ -161,10 +199,8 @@ export function ReportComposer({ try { const response = await fetch('/api/reports/classify', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ content }), + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content: textContent }), }); if (!response.ok) { @@ -173,11 +209,10 @@ export function ReportComposer({ const result: ClassificationResult = await response.json(); setClassification(result); - setSelectedType(result.type); + onTypeChange?.(result.type); } catch (err) { const message = err instanceof Error ? err.message : 'AI-analyse mislukt'; setClassification(null); - setSelectedType('vrije_notitie'); setError(message); toast({ variant: 'destructive', title: 'AI-analyse mislukt', description: message }); } finally { @@ -191,7 +226,7 @@ export function ReportComposer({ try { const created = await createReport(patientId, { type: selectedType, - content, + content: textContent, // Save plain text for now ai_confidence: classification?.confidence, ai_reasoning: classification?.reasoning, }); @@ -202,7 +237,6 @@ export function ReportComposer({ onReportCreated?.(created); setContent(''); setClassification(null); - setSelectedType('vrije_notitie'); if (typeof window !== 'undefined') { window.localStorage.removeItem(draftStorageKey); } @@ -219,19 +253,33 @@ export function ReportComposer({ const insertReference = () => { if (!selectedReport || !referenceSnippet) return; - const prefix = content ? `${content.trim()}\n\n` : ''; - const block = `> ${referenceSnippet.preview}\n(${referenceSnippet.type} β€’ ${referenceSnippet.meta})`; - setContent(`${prefix}${block}\n\n`); + const block = `
${referenceSnippet.preview}

(${referenceSnippet.type} β€’ ${referenceSnippet.meta})

`; + setContent((prev) => (prev ? `${prev}${block}` : block)); }; - return ( -
setShowRecorder((prev) => !prev)} + className={cn( + 'inline-flex h-7 items-center gap-1 px-2 rounded-md text-xs font-medium transition-colors', + showRecorder || isStreaming + ? 'bg-emerald-100 text-emerald-700' + : 'text-slate-600 hover:bg-white hover:text-slate-900' + )} + title="Spraakopname" > + + {isStreaming && ●} + + ); + + return ( +
+ {/* Reference snippet */} {referenceSnippet && ( -
+
Geselecteerde rapportage {referenceSnippet.meta} @@ -239,88 +287,58 @@ export function ReportComposer({

{referenceSnippet.preview}

{referenceSnippet.type} -
)} -
-
-