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:
@@ -52,8 +52,6 @@ export function QuickActions({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex flex-wrap items-center gap-2', className)}>
|
<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) => {
|
{QUICK_ACTIONS.map((action) => {
|
||||||
const Icon = action.icon
|
const Icon = action.icon
|
||||||
const isActive = selectedType === action.type
|
const isActive = selectedType === action.type
|
||||||
|
|||||||
@@ -144,6 +144,8 @@ export function RapportageWorkspaceV2({
|
|||||||
<ReportComposer
|
<ReportComposer
|
||||||
patientId={patientId}
|
patientId={patientId}
|
||||||
patientName={patientName}
|
patientName={patientName}
|
||||||
|
selectedType={selectedType}
|
||||||
|
onTypeChange={handleTypeSelect}
|
||||||
selectedReport={selectedReport}
|
selectedReport={selectedReport}
|
||||||
onReportCreated={handleReportCreated}
|
onReportCreated={handleReportCreated}
|
||||||
initialContent={duplicateContent}
|
initialContent={duplicateContent}
|
||||||
|
|||||||
@@ -142,6 +142,8 @@ export function RapportageWorkspace({
|
|||||||
<ReportComposer
|
<ReportComposer
|
||||||
patientId={patientId}
|
patientId={patientId}
|
||||||
patientName={patientName}
|
patientName={patientName}
|
||||||
|
selectedType={selectedType}
|
||||||
|
onTypeChange={handleTypeSelect}
|
||||||
selectedReport={selectedReport}
|
selectedReport={selectedReport}
|
||||||
onReportCreated={handleReportCreated}
|
onReportCreated={handleReportCreated}
|
||||||
initialContent={duplicateContent}
|
initialContent={duplicateContent}
|
||||||
|
|||||||
@@ -1,19 +1,36 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useMemo, useState, useEffect, useRef, useCallback } from 'react';
|
import { useMemo, useState, useEffect, useCallback } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
|
import { Mic, Sparkles } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { toast } from '@/hooks/use-toast';
|
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 { createReport } from '../actions';
|
||||||
import { cn } from '@/lib/utils';
|
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(
|
const SpeechRecorderStreaming = dynamic(
|
||||||
() => import('@/components/speech-recorder-streaming').then((m) => m.SpeechRecorderStreaming),
|
() => import('@/components/speech-recorder-streaming').then((m) => m.SpeechRecorderStreaming),
|
||||||
{ ssr: false, loading: () => <RecorderSkeleton /> }
|
{ 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() {
|
function RecorderSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-slate-200 bg-white p-4 animate-pulse">
|
<div className="rounded-lg border border-slate-200 bg-white p-4 animate-pulse">
|
||||||
@@ -23,9 +40,17 @@ function RecorderSkeleton() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Types
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface ReportComposerProps {
|
interface ReportComposerProps {
|
||||||
patientId: string;
|
patientId: string;
|
||||||
patientName: string;
|
patientName: string;
|
||||||
|
/** Het geselecteerde report type (van parent) */
|
||||||
|
selectedType: ReportType;
|
||||||
|
/** Callback om type te wijzigen */
|
||||||
|
onTypeChange?: (type: ReportType) => void;
|
||||||
selectedReport?: Report | null;
|
selectedReport?: Report | null;
|
||||||
onReportCreated?: (report: Report) => void;
|
onReportCreated?: (report: Report) => void;
|
||||||
/** Initial content voor de editor (bijv. van duplicate) */
|
/** Initial content voor de editor (bijv. van duplicate) */
|
||||||
@@ -34,64 +59,52 @@ interface ReportComposerProps {
|
|||||||
onInitialContentConsumed?: () => void;
|
onInitialContentConsumed?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Component
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function ReportComposer({
|
export function ReportComposer({
|
||||||
patientId,
|
patientId,
|
||||||
patientName,
|
patientName,
|
||||||
|
selectedType,
|
||||||
|
onTypeChange,
|
||||||
selectedReport,
|
selectedReport,
|
||||||
onReportCreated,
|
onReportCreated,
|
||||||
initialContent,
|
initialContent,
|
||||||
onInitialContentConsumed,
|
onInitialContentConsumed,
|
||||||
}: ReportComposerProps) {
|
}: ReportComposerProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const [editorRef, setEditorRef] = useState<Editor | null>(null);
|
||||||
const [content, setContent] = useState('');
|
const [content, setContent] = useState('');
|
||||||
const [classification, setClassification] = useState<ClassificationResult | null>(null);
|
const [classification, setClassification] = useState<ClassificationResult | null>(null);
|
||||||
const [selectedType, setSelectedType] = useState<'behandeladvies' | 'vrije_notitie'>('vrije_notitie');
|
|
||||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [lastAutosave, setLastAutosave] = useState<Date | null>(null);
|
const [lastAutosave, setLastAutosave] = useState<Date | null>(null);
|
||||||
const [isStreaming, setIsStreaming] = useState(false);
|
const [isStreaming, setIsStreaming] = useState(false);
|
||||||
const [interimText, setInterimText] = useState('');
|
const [showRecorder, setShowRecorder] = useState(false);
|
||||||
const draftStorageKey = useMemo(() => `rapportage-draft-${patientId}`, [patientId]);
|
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)
|
// Handle initialContent (e.g., from duplicate)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialContent) {
|
if (initialContent) {
|
||||||
setContent(initialContent);
|
setContent(initialContent);
|
||||||
onInitialContentConsumed?.();
|
onInitialContentConsumed?.();
|
||||||
// Focus en scroll naar einde
|
|
||||||
setTimeout(() => {
|
|
||||||
if (textareaRef.current) {
|
|
||||||
textareaRef.current.focus();
|
|
||||||
textareaRef.current.scrollTop = textareaRef.current.scrollHeight;
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
}
|
}
|
||||||
}, [initialContent, onInitialContentConsumed]);
|
}, [initialContent, onInitialContentConsumed]);
|
||||||
|
|
||||||
// Cursor naar einde verplaatsen bij start opname
|
// Load draft from localStorage
|
||||||
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;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
const stored = window.localStorage.getItem(draftStorageKey);
|
const stored = window.localStorage.getItem(draftStorageKey);
|
||||||
@@ -99,14 +112,14 @@ export function ReportComposer({
|
|||||||
try {
|
try {
|
||||||
const draft = JSON.parse(stored) as {
|
const draft = JSON.parse(stored) as {
|
||||||
content?: string;
|
content?: string;
|
||||||
type?: 'behandeladvies' | 'vrije_notitie';
|
type?: ReportType;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
};
|
};
|
||||||
if (draft.content) {
|
if (draft.content) {
|
||||||
setContent(draft.content);
|
setContent(draft.content);
|
||||||
}
|
}
|
||||||
if (draft.type) {
|
if (draft.type && onTypeChange) {
|
||||||
setSelectedType(draft.type);
|
onTypeChange(draft.type);
|
||||||
}
|
}
|
||||||
if (draft.updatedAt) {
|
if (draft.updatedAt) {
|
||||||
setLastAutosave(new Date(draft.updatedAt));
|
setLastAutosave(new Date(draft.updatedAt));
|
||||||
@@ -116,8 +129,9 @@ export function ReportComposer({
|
|||||||
window.localStorage.removeItem(draftStorageKey);
|
window.localStorage.removeItem(draftStorageKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [draftStorageKey]);
|
}, [draftStorageKey, onTypeChange]);
|
||||||
|
|
||||||
|
// Autosave draft
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
if (!content && selectedType === 'vrije_notitie') {
|
if (!content && selectedType === 'vrije_notitie') {
|
||||||
@@ -139,6 +153,30 @@ export function ReportComposer({
|
|||||||
return () => window.clearTimeout(timeout);
|
return () => window.clearTimeout(timeout);
|
||||||
}, [content, selectedType, draftStorageKey]);
|
}, [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(() => {
|
const referenceSnippet = useMemo(() => {
|
||||||
if (!selectedReport) return null;
|
if (!selectedReport) return null;
|
||||||
const createdAt = selectedReport.created_at ? new Date(selectedReport.created_at) : null;
|
const createdAt = selectedReport.created_at ? new Date(selectedReport.created_at) : null;
|
||||||
@@ -161,10 +199,8 @@ export function ReportComposer({
|
|||||||
try {
|
try {
|
||||||
const response = await fetch('/api/reports/classify', {
|
const response = await fetch('/api/reports/classify', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
'Content-Type': 'application/json',
|
body: JSON.stringify({ content: textContent }),
|
||||||
},
|
|
||||||
body: JSON.stringify({ content }),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -173,11 +209,10 @@ export function ReportComposer({
|
|||||||
|
|
||||||
const result: ClassificationResult = await response.json();
|
const result: ClassificationResult = await response.json();
|
||||||
setClassification(result);
|
setClassification(result);
|
||||||
setSelectedType(result.type);
|
onTypeChange?.(result.type);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'AI-analyse mislukt';
|
const message = err instanceof Error ? err.message : 'AI-analyse mislukt';
|
||||||
setClassification(null);
|
setClassification(null);
|
||||||
setSelectedType('vrije_notitie');
|
|
||||||
setError(message);
|
setError(message);
|
||||||
toast({ variant: 'destructive', title: 'AI-analyse mislukt', description: message });
|
toast({ variant: 'destructive', title: 'AI-analyse mislukt', description: message });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -191,7 +226,7 @@ export function ReportComposer({
|
|||||||
try {
|
try {
|
||||||
const created = await createReport(patientId, {
|
const created = await createReport(patientId, {
|
||||||
type: selectedType,
|
type: selectedType,
|
||||||
content,
|
content: textContent, // Save plain text for now
|
||||||
ai_confidence: classification?.confidence,
|
ai_confidence: classification?.confidence,
|
||||||
ai_reasoning: classification?.reasoning,
|
ai_reasoning: classification?.reasoning,
|
||||||
});
|
});
|
||||||
@@ -202,7 +237,6 @@ export function ReportComposer({
|
|||||||
onReportCreated?.(created);
|
onReportCreated?.(created);
|
||||||
setContent('');
|
setContent('');
|
||||||
setClassification(null);
|
setClassification(null);
|
||||||
setSelectedType('vrije_notitie');
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.localStorage.removeItem(draftStorageKey);
|
window.localStorage.removeItem(draftStorageKey);
|
||||||
}
|
}
|
||||||
@@ -219,19 +253,33 @@ export function ReportComposer({
|
|||||||
|
|
||||||
const insertReference = () => {
|
const insertReference = () => {
|
||||||
if (!selectedReport || !referenceSnippet) return;
|
if (!selectedReport || !referenceSnippet) return;
|
||||||
const prefix = content ? `${content.trim()}\n\n` : '';
|
const block = `<blockquote>${referenceSnippet.preview}</blockquote><p><em>(${referenceSnippet.type} • ${referenceSnippet.meta})</em></p>`;
|
||||||
const block = `> ${referenceSnippet.preview}\n(${referenceSnippet.type} • ${referenceSnippet.meta})`;
|
setContent((prev) => (prev ? `${prev}${block}` : block));
|
||||||
setContent(`${prefix}${block}\n\n`);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
// Mic button for toolbar
|
||||||
<section
|
const MicToolbarButton = (
|
||||||
id="rapportage-composer"
|
<button
|
||||||
tabIndex={-1}
|
type="button"
|
||||||
className="focus:outline-none"
|
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 && (
|
{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">
|
<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 className="font-semibold text-slate-700">Geselecteerde rapportage</span>
|
||||||
<span>{referenceSnippet.meta}</span>
|
<span>{referenceSnippet.meta}</span>
|
||||||
@@ -239,88 +287,58 @@ export function ReportComposer({
|
|||||||
<p className="mt-2 text-sm text-slate-700">{referenceSnippet.preview}</p>
|
<p className="mt-2 text-sm text-slate-700">{referenceSnippet.preview}</p>
|
||||||
<div className="mt-3 flex items-center justify-between">
|
<div className="mt-3 flex items-center justify-between">
|
||||||
<span className="text-xs uppercase tracking-wide text-slate-400">{referenceSnippet.type}</span>
|
<span className="text-xs uppercase tracking-wide text-slate-400">{referenceSnippet.type}</span>
|
||||||
<Button
|
<Button type="button" variant="outline" size="sm" onClick={insertReference} disabled={isSaving}>
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={insertReference}
|
|
||||||
disabled={isSaving}
|
|
||||||
>
|
|
||||||
Voeg referentie toe
|
Voeg referentie toe
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
{/* Editor */}
|
||||||
<div>
|
<RichTextEditor
|
||||||
<textarea
|
value={content}
|
||||||
ref={textareaRef}
|
onChange={setContent}
|
||||||
value={content}
|
placeholder="Begin met typen of gebruik spraakopname..."
|
||||||
onChange={(e) => setContent(e.target.value)}
|
toolbarExtra={MicToolbarButton}
|
||||||
placeholder="Beschrijf wat je wilt vastleggen..."
|
onEditorReady={setEditorRef}
|
||||||
className={cn(
|
isStreaming={isStreaming}
|
||||||
'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',
|
minHeight="180px"
|
||||||
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>
|
|
||||||
|
|
||||||
<SpeechRecorderStreaming
|
{/* Character count */}
|
||||||
disabled={isSaving || isAnalyzing}
|
<div className="flex justify-between text-xs text-slate-500 -mt-2">
|
||||||
onTranscript={(text) =>
|
<span>{characterCount} / 5000 karakters</span>
|
||||||
setContent((prev) => (prev ? `${prev} ${text}` : text))
|
{characterCount > 0 && characterCount < 20 && <span className="text-amber-600">Minimaal 20 karakters</span>}
|
||||||
}
|
|
||||||
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>}
|
|
||||||
</div>
|
</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">
|
<p className="text-xs text-slate-500">
|
||||||
{isSaving
|
{isSaving
|
||||||
? 'Opslaan bezig…'
|
? 'Opslaan bezig…'
|
||||||
@@ -338,13 +356,10 @@ export function ReportComposer({
|
|||||||
onClick={analyzeWithAI}
|
onClick={analyzeWithAI}
|
||||||
disabled={contentInvalid || isAnalyzing || isSaving}
|
disabled={contentInvalid || isAnalyzing || isSaving}
|
||||||
>
|
>
|
||||||
|
<Sparkles className="h-4 w-4 mr-1" />
|
||||||
{isAnalyzing ? 'Analyseren…' : 'Analyseer met AI'}
|
{isAnalyzing ? 'Analyseren…' : 'Analyseer met AI'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button type="button" onClick={saveReport} disabled={contentInvalid || isSaving}>
|
||||||
type="button"
|
|
||||||
onClick={saveReport}
|
|
||||||
disabled={contentInvalid || isSaving}
|
|
||||||
>
|
|
||||||
{isSaving ? 'Opslaan…' : 'Opslaan'}
|
{isSaving ? 'Opslaan…' : 'Opslaan'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect, type ReactNode } from 'react';
|
||||||
import { EditorContent, useEditor } from '@tiptap/react';
|
import { EditorContent, useEditor, type Editor } from '@tiptap/react';
|
||||||
import StarterKit from '@tiptap/starter-kit';
|
import StarterKit from '@tiptap/starter-kit';
|
||||||
import Placeholder from '@tiptap/extension-placeholder';
|
import Placeholder from '@tiptap/extension-placeholder';
|
||||||
import { Bold, Italic, List, ListOrdered, Quote } from 'lucide-react';
|
import { Bold, Italic, List, ListOrdered, Quote } from 'lucide-react';
|
||||||
@@ -11,9 +11,25 @@ interface RichTextEditorProps {
|
|||||||
value?: string;
|
value?: string;
|
||||||
onChange?: (value: string) => void;
|
onChange?: (value: string) => void;
|
||||||
placeholder?: string;
|
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({
|
const editor = useEditor({
|
||||||
extensions: [
|
extensions: [
|
||||||
StarterKit.configure({
|
StarterKit.configure({
|
||||||
@@ -26,12 +42,12 @@ export function RichTextEditor({ value, onChange, placeholder }: RichTextEditorP
|
|||||||
content: value || '',
|
content: value || '',
|
||||||
editorProps: {
|
editorProps: {
|
||||||
attributes: {
|
attributes: {
|
||||||
class:
|
class: `prose prose-sm max-w-none focus:outline-none px-3 py-2 text-slate-800 bg-white`,
|
||||||
'prose prose-sm max-w-none focus:outline-none min-h-[160px] px-3 py-2 text-slate-800 bg-white',
|
style: `min-height: ${minHeight}`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
onUpdate({ editor }) {
|
onUpdate({ editor: e }) {
|
||||||
onChange?.(editor.getHTML());
|
onChange?.(e.getHTML());
|
||||||
},
|
},
|
||||||
immediatelyRender: false,
|
immediatelyRender: false,
|
||||||
});
|
});
|
||||||
@@ -44,6 +60,13 @@ export function RichTextEditor({ value, onChange, placeholder }: RichTextEditorP
|
|||||||
}
|
}
|
||||||
}, [value, editor]);
|
}, [value, editor]);
|
||||||
|
|
||||||
|
// Notify parent when editor is ready
|
||||||
|
useEffect(() => {
|
||||||
|
if (editor && onEditorReady) {
|
||||||
|
onEditorReady(editor);
|
||||||
|
}
|
||||||
|
}, [editor, onEditorReady]);
|
||||||
|
|
||||||
if (!editor) {
|
if (!editor) {
|
||||||
return <div className="rounded-lg border border-slate-200 h-32 animate-pulse bg-slate-50" />;
|
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 (
|
return (
|
||||||
<div className="rounded-lg border border-slate-200 overflow-hidden">
|
<div className={cn(
|
||||||
<div className="flex items-center gap-1 border-b border-slate-200 bg-slate-50 px-2 py-1.5">
|
'rounded-lg border overflow-hidden transition-all duration-200',
|
||||||
<button
|
isStreaming
|
||||||
type="button"
|
? 'border-emerald-500 border-2 shadow-emerald-500/20 shadow-md'
|
||||||
onClick={() => toggle(() => editor.chain().focus().toggleBold().run())}
|
: 'border-slate-200'
|
||||||
className={cn(
|
)}>
|
||||||
'inline-flex h-7 w-7 items-center justify-center rounded-md text-xs text-slate-600 hover:bg-white',
|
<div className="flex items-center justify-between border-b border-slate-200 bg-slate-50 px-2 py-1.5">
|
||||||
editor.isActive('bold') && 'bg-white text-slate-900'
|
<div className="flex items-center gap-1">
|
||||||
)}
|
<button
|
||||||
>
|
type="button"
|
||||||
<Bold className="h-4 w-4" />
|
onClick={() => toggle(() => editor.chain().focus().toggleBold().run())}
|
||||||
</button>
|
className={cn(
|
||||||
<button
|
'inline-flex h-7 w-7 items-center justify-center rounded-md text-xs text-slate-600 hover:bg-white',
|
||||||
type="button"
|
editor.isActive('bold') && 'bg-white text-slate-900'
|
||||||
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',
|
<Bold className="h-4 w-4" />
|
||||||
editor.isActive('italic') && 'bg-white text-slate-900'
|
</button>
|
||||||
)}
|
<button
|
||||||
>
|
type="button"
|
||||||
<Italic className="h-4 w-4" />
|
onClick={() => toggle(() => editor.chain().focus().toggleItalic().run())}
|
||||||
</button>
|
className={cn(
|
||||||
<button
|
'inline-flex h-7 w-7 items-center justify-center rounded-md text-xs text-slate-600 hover:bg-white',
|
||||||
type="button"
|
editor.isActive('italic') && 'bg-white text-slate-900'
|
||||||
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',
|
<Italic className="h-4 w-4" />
|
||||||
editor.isActive('bulletList') && 'bg-white text-slate-900'
|
</button>
|
||||||
)}
|
<button
|
||||||
>
|
type="button"
|
||||||
<List className="h-4 w-4" />
|
onClick={() => toggle(() => editor.chain().focus().toggleBulletList().run())}
|
||||||
</button>
|
className={cn(
|
||||||
<button
|
'inline-flex h-7 w-7 items-center justify-center rounded-md text-xs text-slate-600 hover:bg-white',
|
||||||
type="button"
|
editor.isActive('bulletList') && 'bg-white text-slate-900'
|
||||||
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',
|
<List className="h-4 w-4" />
|
||||||
editor.isActive('orderedList') && 'bg-white text-slate-900'
|
</button>
|
||||||
)}
|
<button
|
||||||
>
|
type="button"
|
||||||
<ListOrdered className="h-4 w-4" />
|
onClick={() => toggle(() => editor.chain().focus().toggleOrderedList().run())}
|
||||||
</button>
|
className={cn(
|
||||||
<button
|
'inline-flex h-7 w-7 items-center justify-center rounded-md text-xs text-slate-600 hover:bg-white',
|
||||||
type="button"
|
editor.isActive('orderedList') && 'bg-white text-slate-900'
|
||||||
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',
|
<ListOrdered className="h-4 w-4" />
|
||||||
editor.isActive('blockquote') && 'bg-white text-slate-900'
|
</button>
|
||||||
)}
|
<button
|
||||||
>
|
type="button"
|
||||||
<Quote className="h-4 w-4" />
|
onClick={() => toggle(() => editor.chain().focus().toggleBlockquote().run())}
|
||||||
</button>
|
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>
|
</div>
|
||||||
<EditorContent editor={editor} />
|
<EditorContent editor={editor} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Export Editor type for external use
|
||||||
|
export type { Editor } from '@tiptap/react';
|
||||||
|
|||||||
BIN
docs/troubleshooting/screenprint-nieuwe-rapportage-ux.png
Normal file
BIN
docs/troubleshooting/screenprint-nieuwe-rapportage-ux.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 127 KiB |
Reference in New Issue
Block a user