feat: improve rapportage composer UX with TipTap editor

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 <noreply@anthropic.com>
This commit is contained in:
colinislit
2025-11-25 14:46:03 +01:00
parent dce3943963
commit 779ab85e5d
6 changed files with 251 additions and 194 deletions

View File

@@ -52,8 +52,6 @@ export function QuickActions({
return (
<div className={cn('flex flex-wrap items-center gap-2', className)}>
<span className="text-sm text-slate-500 mr-1">Nieuwe rapportage:</span>
{QUICK_ACTIONS.map((action) => {
const Icon = action.icon
const isActive = selectedType === action.type

View File

@@ -144,6 +144,8 @@ export function RapportageWorkspaceV2({
<ReportComposer
patientId={patientId}
patientName={patientName}
selectedType={selectedType}
onTypeChange={handleTypeSelect}
selectedReport={selectedReport}
onReportCreated={handleReportCreated}
initialContent={duplicateContent}

View File

@@ -142,6 +142,8 @@ export function RapportageWorkspace({
<ReportComposer
patientId={patientId}
patientName={patientName}
selectedType={selectedType}
onTypeChange={handleTypeSelect}
selectedReport={selectedReport}
onReportCreated={handleReportCreated}
initialContent={duplicateContent}

View File

@@ -1,19 +1,36 @@
'use client';
import { useMemo, useState, useEffect, useRef, useCallback } from 'react';
import { useMemo, useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import dynamic from 'next/dynamic';
import { Mic, Sparkles } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { toast } from '@/hooks/use-toast';
import type { ClassificationResult, Report } from '@/lib/types/report';
import type { ClassificationResult, Report, ReportType } from '@/lib/types/report';
import { createReport } from '../actions';
import { cn } from '@/lib/utils';
import type { Editor } from '@/components/rich-text-editor';
// Dynamic imports
const RichTextEditor = dynamic(
() => import('@/components/rich-text-editor').then((m) => m.RichTextEditor),
{ ssr: false, loading: () => <EditorSkeleton /> }
);
const SpeechRecorderStreaming = dynamic(
() => import('@/components/speech-recorder-streaming').then((m) => m.SpeechRecorderStreaming),
{ ssr: false, loading: () => <RecorderSkeleton /> }
);
function EditorSkeleton() {
return (
<div className="rounded-lg border border-slate-200 animate-pulse">
<div className="h-10 bg-slate-100 border-b border-slate-200" />
<div className="h-40 bg-white" />
</div>
);
}
function RecorderSkeleton() {
return (
<div className="rounded-lg border border-slate-200 bg-white p-4 animate-pulse">
@@ -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<HTMLTextAreaElement>(null);
const [editorRef, setEditorRef] = useState<Editor | null>(null);
const [content, setContent] = useState('');
const [classification, setClassification] = useState<ClassificationResult | null>(null);
const [selectedType, setSelectedType] = useState<'behandeladvies' | 'vrije_notitie'>('vrije_notitie');
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [lastAutosave, setLastAutosave] = useState<Date | null>(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}<p>${text}</p>`;
}
return `<p>${text}</p>`;
});
}, []);
// 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 = `<blockquote>${referenceSnippet.preview}</blockquote><p><em>(${referenceSnippet.type}${referenceSnippet.meta})</em></p>`;
setContent((prev) => (prev ? `${prev}${block}` : block));
};
return (
<section
id="rapportage-composer"
tabIndex={-1}
className="focus:outline-none"
// Mic button for toolbar
const MicToolbarButton = (
<button
type="button"
onClick={() => 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"
>
<Mic className={cn('h-4 w-4', isStreaming && 'text-emerald-600 animate-pulse')} />
{isStreaming && <span className="text-emerald-600"></span>}
</button>
);
return (
<section id="rapportage-composer" tabIndex={-1} className="focus:outline-none space-y-4">
{/* Reference snippet */}
{referenceSnippet && (
<div className="mb-4 rounded-xl border border-slate-100 bg-slate-50 p-4">
<div className="rounded-xl border border-slate-100 bg-slate-50 p-4">
<div className="flex items-center justify-between gap-2 text-xs text-slate-500">
<span className="font-semibold text-slate-700">Geselecteerde rapportage</span>
<span>{referenceSnippet.meta}</span>
@@ -239,88 +287,58 @@ export function ReportComposer({
<p className="mt-2 text-sm text-slate-700">{referenceSnippet.preview}</p>
<div className="mt-3 flex items-center justify-between">
<span className="text-xs uppercase tracking-wide text-slate-400">{referenceSnippet.type}</span>
<Button
type="button"
variant="outline"
size="sm"
onClick={insertReference}
disabled={isSaving}
>
<Button type="button" variant="outline" size="sm" onClick={insertReference} disabled={isSaving}>
Voeg referentie toe
</Button>
</div>
</div>
)}
<div className="flex flex-col gap-4">
<div>
<textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Beschrijf wat je wilt vastleggen..."
className={cn(
'w-full min-h-[200px] rounded-xl border bg-white p-4 text-sm text-slate-900 shadow-inner focus:outline-none transition-all duration-200',
isStreaming
? 'border-emerald-500 border-2 shadow-emerald-500/20 shadow-md'
: 'border-slate-200 focus:border-teal-500'
)}
/>
{/* Interim tekst preview tijdens streaming */}
{interimText && (
<div className="mt-1 text-sm text-slate-500 italic px-1">
{interimText}
</div>
)}
<div className="flex justify-between text-xs text-slate-500 mt-1">
<span>{characterCount} / 5000 karakters</span>
{contentInvalid && <span>Minimaal 20 karakters</span>}
</div>
</div>
{/* Editor */}
<RichTextEditor
value={content}
onChange={setContent}
placeholder="Begin met typen of gebruik spraakopname..."
toolbarExtra={MicToolbarButton}
onEditorReady={setEditorRef}
isStreaming={isStreaming}
minHeight="180px"
/>
<SpeechRecorderStreaming
disabled={isSaving || isAnalyzing}
onTranscript={(text) =>
setContent((prev) => (prev ? `${prev} ${text}` : text))
}
onInterimTranscript={setInterimText}
onRecordingStart={handleRecordingStart}
onRecordingStop={handleRecordingStop}
telemetryContext={{
context: 'report_composer',
patientId,
}}
/>
<div className="rounded-xl border border-slate-200 p-4 text-sm">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-slate-900">Rapportagetype</p>
<p className="text-xs text-slate-500">Stel handmatig in of gebruik het AI-voorstel.</p>
</div>
<span className="text-xs text-slate-500">
{classification
? `AI: ${classification.type} (${Math.round(classification.confidence * 100)}%)`
: 'Nog geen AI-analyse'}
</span>
</div>
{classification?.reasoning && (
<p className="mt-2 text-xs text-slate-500">{classification.reasoning}</p>
)}
<select
value={selectedType}
onChange={(e) => setSelectedType(e.target.value as 'behandeladvies' | 'vrije_notitie')}
className="mt-3 w-full rounded-md border border-slate-200 bg-white p-2 text-sm"
>
<option value="behandeladvies">Behandeladvies</option>
<option value="vrije_notitie">Vrije notitie</option>
</select>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
{/* Character count */}
<div className="flex justify-between text-xs text-slate-500 -mt-2">
<span>{characterCount} / 5000 karakters</span>
{characterCount > 0 && characterCount < 20 && <span className="text-amber-600">Minimaal 20 karakters</span>}
</div>
<div className="mt-6 flex flex-wrap items-center justify-between gap-3 border-t border-slate-100 pt-4">
{/* Speech recorder (collapsible) */}
{showRecorder && (
<div className="animate-in slide-in-from-top-2 duration-200">
<SpeechRecorderStreaming
disabled={isSaving || isAnalyzing}
onTranscript={handleTranscript}
onRecordingStart={handleRecordingStart}
onRecordingStop={handleRecordingStop}
telemetryContext={{ context: 'report_composer', patientId }}
/>
</div>
)}
{/* AI Classification result (inline) */}
{classification && (
<div className="flex items-center gap-2 text-sm text-slate-600 bg-slate-50 rounded-lg px-3 py-2">
<Sparkles className="h-4 w-4 text-amber-500" />
<span>
AI suggereert: <strong>{classification.type === 'behandeladvies' ? 'Behandeladvies' : 'Vrije notitie'}</strong>
<span className="text-slate-400 ml-1">({Math.round(classification.confidence * 100)}% zeker)</span>
</span>
</div>
)}
{error && <p className="text-sm text-red-600">{error}</p>}
{/* Footer */}
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-slate-100 pt-4">
<p className="text-xs text-slate-500">
{isSaving
? 'Opslaan bezig…'
@@ -338,13 +356,10 @@ export function ReportComposer({
onClick={analyzeWithAI}
disabled={contentInvalid || isAnalyzing || isSaving}
>
<Sparkles className="h-4 w-4 mr-1" />
{isAnalyzing ? 'Analyseren…' : 'Analyseer met AI'}
</Button>
<Button
type="button"
onClick={saveReport}
disabled={contentInvalid || isSaving}
>
<Button type="button" onClick={saveReport} disabled={contentInvalid || isSaving}>
{isSaving ? 'Opslaan…' : 'Opslaan'}
</Button>
</div>

View File

@@ -1,7 +1,7 @@
'use client';
import { useEffect } from 'react';
import { EditorContent, useEditor } from '@tiptap/react';
import { useEffect, type ReactNode } from 'react';
import { EditorContent, useEditor, type Editor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Placeholder from '@tiptap/extension-placeholder';
import { Bold, Italic, List, ListOrdered, Quote } from 'lucide-react';
@@ -11,9 +11,25 @@ interface RichTextEditorProps {
value?: string;
onChange?: (value: string) => void;
placeholder?: string;
/** Extra content voor in de toolbar (bijv. mic button) */
toolbarExtra?: ReactNode;
/** Callback met editor instance voor externe controle */
onEditorReady?: (editor: Editor) => void;
/** Highlight border tijdens streaming */
isStreaming?: boolean;
/** Min height voor editor */
minHeight?: string;
}
export function RichTextEditor({ value, onChange, placeholder }: RichTextEditorProps) {
export function RichTextEditor({
value,
onChange,
placeholder,
toolbarExtra,
onEditorReady,
isStreaming = false,
minHeight = '160px',
}: RichTextEditorProps) {
const editor = useEditor({
extensions: [
StarterKit.configure({
@@ -26,12 +42,12 @@ export function RichTextEditor({ value, onChange, placeholder }: RichTextEditorP
content: value || '',
editorProps: {
attributes: {
class:
'prose prose-sm max-w-none focus:outline-none min-h-[160px] px-3 py-2 text-slate-800 bg-white',
class: `prose prose-sm max-w-none focus:outline-none px-3 py-2 text-slate-800 bg-white`,
style: `min-height: ${minHeight}`,
},
},
onUpdate({ editor }) {
onChange?.(editor.getHTML());
onUpdate({ editor: e }) {
onChange?.(e.getHTML());
},
immediatelyRender: false,
});
@@ -44,6 +60,13 @@ export function RichTextEditor({ value, onChange, placeholder }: RichTextEditorP
}
}, [value, editor]);
// Notify parent when editor is ready
useEffect(() => {
if (editor && onEditorReady) {
onEditorReady(editor);
}
}, [editor, onEditorReady]);
if (!editor) {
return <div className="rounded-lg border border-slate-200 h-32 animate-pulse bg-slate-50" />;
}
@@ -54,60 +77,77 @@ export function RichTextEditor({ value, onChange, placeholder }: RichTextEditorP
};
return (
<div className="rounded-lg border border-slate-200 overflow-hidden">
<div className="flex items-center gap-1 border-b border-slate-200 bg-slate-50 px-2 py-1.5">
<button
type="button"
onClick={() => toggle(() => editor.chain().focus().toggleBold().run())}
className={cn(
'inline-flex h-7 w-7 items-center justify-center rounded-md text-xs text-slate-600 hover:bg-white',
editor.isActive('bold') && 'bg-white text-slate-900'
)}
>
<Bold className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => toggle(() => editor.chain().focus().toggleItalic().run())}
className={cn(
'inline-flex h-7 w-7 items-center justify-center rounded-md text-xs text-slate-600 hover:bg-white',
editor.isActive('italic') && 'bg-white text-slate-900'
)}
>
<Italic className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => toggle(() => editor.chain().focus().toggleBulletList().run())}
className={cn(
'inline-flex h-7 w-7 items-center justify-center rounded-md text-xs text-slate-600 hover:bg-white',
editor.isActive('bulletList') && 'bg-white text-slate-900'
)}
>
<List className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => toggle(() => editor.chain().focus().toggleOrderedList().run())}
className={cn(
'inline-flex h-7 w-7 items-center justify-center rounded-md text-xs text-slate-600 hover:bg-white',
editor.isActive('orderedList') && 'bg-white text-slate-900'
)}
>
<ListOrdered className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => toggle(() => editor.chain().focus().toggleBlockquote().run())}
className={cn(
'inline-flex h-7 w-7 items-center justify-center rounded-md text-xs text-slate-600 hover:bg-white',
editor.isActive('blockquote') && 'bg-white text-slate-900'
)}
>
<Quote className="h-4 w-4" />
</button>
<div className={cn(
'rounded-lg border overflow-hidden transition-all duration-200',
isStreaming
? 'border-emerald-500 border-2 shadow-emerald-500/20 shadow-md'
: 'border-slate-200'
)}>
<div className="flex items-center justify-between border-b border-slate-200 bg-slate-50 px-2 py-1.5">
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => toggle(() => editor.chain().focus().toggleBold().run())}
className={cn(
'inline-flex h-7 w-7 items-center justify-center rounded-md text-xs text-slate-600 hover:bg-white',
editor.isActive('bold') && 'bg-white text-slate-900'
)}
>
<Bold className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => toggle(() => editor.chain().focus().toggleItalic().run())}
className={cn(
'inline-flex h-7 w-7 items-center justify-center rounded-md text-xs text-slate-600 hover:bg-white',
editor.isActive('italic') && 'bg-white text-slate-900'
)}
>
<Italic className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => toggle(() => editor.chain().focus().toggleBulletList().run())}
className={cn(
'inline-flex h-7 w-7 items-center justify-center rounded-md text-xs text-slate-600 hover:bg-white',
editor.isActive('bulletList') && 'bg-white text-slate-900'
)}
>
<List className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => toggle(() => editor.chain().focus().toggleOrderedList().run())}
className={cn(
'inline-flex h-7 w-7 items-center justify-center rounded-md text-xs text-slate-600 hover:bg-white',
editor.isActive('orderedList') && 'bg-white text-slate-900'
)}
>
<ListOrdered className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => toggle(() => editor.chain().focus().toggleBlockquote().run())}
className={cn(
'inline-flex h-7 w-7 items-center justify-center rounded-md text-xs text-slate-600 hover:bg-white',
editor.isActive('blockquote') && 'bg-white text-slate-900'
)}
>
<Quote className="h-4 w-4" />
</button>
</div>
{/* Extra toolbar content (e.g., mic button) */}
{toolbarExtra && (
<div className="flex items-center gap-1">
{toolbarExtra}
</div>
)}
</div>
<EditorContent editor={editor} />
</div>
);
}
// Export Editor type for external use
export type { Editor } from '@tiptap/react';

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB