feat: add speech telemetry and bundle guard

This commit is contained in:
colinislit
2025-11-25 13:37:00 +01:00
parent b6c5d1a6d3
commit 7033329e0f
17 changed files with 2258 additions and 481 deletions

View File

@@ -1,11 +1,38 @@
'use client';
import { useState, useTransition } from 'react';
import { useState, useTransition, useCallback } from 'react';
import { saveTreatmentAdvice } from '../../actions';
import { Loader2, Calendar, UserCircle, ClipboardList, Share2, CheckCircle2 } from 'lucide-react';
import Link from 'next/link';
import { RichTextEditor } from '@/components/rich-text-editor';
import { SpeechRecorder } from '@/components/speech-recorder';
import dynamic from 'next/dynamic';
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="space-y-2">
<div className="h-8 w-3/4 rounded bg-slate-100 animate-pulse" />
<div className="rounded-lg border border-slate-200 h-32 animate-pulse bg-slate-50" />
</div>
);
}
function RecorderSkeleton() {
return (
<div className="rounded-lg border border-slate-200 bg-white p-4 animate-pulse">
<div className="h-4 w-1/2 rounded bg-slate-100 mb-2" />
<div className="h-10 rounded bg-slate-100" />
</div>
);
}
interface AdviceData {
advice?: string;
@@ -52,8 +79,10 @@ export function TreatmentAdviceForm({ patientId, intakeId, initialData, initialD
const [finalize, setFinalize] = useState(Boolean(initialData.outcome));
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const [isStreaming, setIsStreaming] = useState(false);
const [interimText, setInterimText] = useState('');
const appendTranscript = (text: string) => {
const appendTranscript = useCallback((text: string) => {
if (!text) return;
const sanitized = text
.split('\n')
@@ -67,7 +96,16 @@ export function TreatmentAdviceForm({ patientId, intakeId, initialData, initialD
)
.join('');
setForm((prev) => ({ ...prev, advice: `${prev.advice || ''}${sanitized}` }));
};
}, []);
const handleRecordingStart = useCallback(() => {
setIsStreaming(true);
}, []);
const handleRecordingStop = useCallback(() => {
setIsStreaming(false);
setInterimText('');
}, []);
const handleSubmit = () => {
if (!form.advice) {
@@ -217,7 +255,22 @@ export function TreatmentAdviceForm({ patientId, intakeId, initialData, initialD
</div>
<div className="space-y-3">
<SpeechRecorder onTranscript={appendTranscript} />
<SpeechRecorderStreaming
onTranscript={appendTranscript}
onInterimTranscript={setInterimText}
onRecordingStart={handleRecordingStart}
onRecordingStop={handleRecordingStop}
telemetryContext={{
context: 'treatment_advice',
patientId,
intakeId,
}}
/>
{interimText && (
<div className="text-sm text-slate-500 italic bg-slate-50 p-2 rounded border border-slate-200">
{interimText}
</div>
)}
<RichTextEditor
value={form.advice}
onChange={(html) => setForm((prev) => ({ ...prev, advice: html }))}

View File

@@ -1,7 +1,26 @@
import { NewIntakeForm } from '../components/new-intake-form';
import dynamic from 'next/dynamic';
import Link from 'next/link';
import { ChevronLeft } from 'lucide-react';
const NewIntakeForm = dynamic(
() => import('../components/new-intake-form').then((m) => m.NewIntakeForm),
{ ssr: false, loading: () => <FormSkeleton /> }
);
function FormSkeleton() {
return (
<div className="space-y-4 animate-pulse">
<div className="h-5 w-1/3 rounded bg-slate-200" />
<div className="h-10 rounded bg-slate-100" />
<div className="h-5 w-1/4 rounded bg-slate-200" />
<div className="h-10 rounded bg-slate-100" />
<div className="h-5 w-1/4 rounded bg-slate-200" />
<div className="h-10 rounded bg-slate-100" />
<div className="h-10 rounded bg-slate-200" />
</div>
);
}
interface NewIntakePageProps {
params: Promise<{ id: string }>;
}
@@ -31,4 +50,3 @@ export default async function NewIntakePage({ params }: NewIntakePageProps) {
</div>
);
}

View File

@@ -0,0 +1,240 @@
'use client'
import { useState, useCallback, useEffect } from 'react'
import dynamic from 'next/dynamic'
import { ChevronRight, ChevronLeft } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { Report } from '@/lib/types/report'
import { ReportComposer } from './report-composer'
import { QuickActions, type ReportType } from './quick-actions'
import { ReportTimeline } from './report-timeline'
const ReportViewEditModal = dynamic(
() => import('./report-view-edit-modal').then((m) => m.ReportViewEditModal),
{ ssr: false, loading: () => <ModalSkeleton /> }
)
function ModalSkeleton() {
return (
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/30">
<div className="rounded-2xl bg-white/80 p-6 shadow-xl">
<div className="h-4 w-48 rounded bg-slate-200 animate-pulse mb-4" />
<div className="h-32 w-72 rounded bg-slate-100 animate-pulse" />
</div>
</div>
)
}
// ─────────────────────────────────────────────────────────────────────────────
// Types
// ─────────────────────────────────────────────────────────────────────────────
interface RapportageWorkspaceV2Props {
patientId: string
patientName: string
initialReports: Report[]
}
type PanelsModule = typeof import('react-resizable-panels')
// ─────────────────────────────────────────────────────────────────────────────
// Component
// ─────────────────────────────────────────────────────────────────────────────
export function RapportageWorkspaceV2({
patientId,
patientName,
initialReports,
}: RapportageWorkspaceV2Props) {
const [reports, setReports] = useState(initialReports)
const [selectedType, setSelectedType] = useState<ReportType>('vrije_notitie')
const [selectedReport, setSelectedReport] = useState<Report | null>(null)
const [isModalOpen, setIsModalOpen] = useState(false)
const [duplicateContent, setDuplicateContent] = useState<string | null>(null)
const [isComposerCollapsed, setIsComposerCollapsed] = useState(false)
const [panelsLib, setPanelsLib] = useState<PanelsModule | null>(null)
useEffect(() => {
let active = true
import('react-resizable-panels')
.then((mod) => {
if (active) {
setPanelsLib(mod)
}
})
.catch((error) => {
console.error('Kon react-resizable-panels niet laden', error)
})
return () => {
active = false
}
}, [])
// Handlers
const handleReportCreated = useCallback((report: Report) => {
setReports((prev) => [report, ...prev])
}, [])
const handleSelectReport = useCallback((report: Report) => {
setSelectedReport(report)
setIsModalOpen(true)
}, [])
const handleCloseModal = useCallback(() => {
setIsModalOpen(false)
}, [])
const handleReportUpdated = useCallback((updatedReport: Report) => {
setReports((prev) =>
prev.map((r) => (r.id === updatedReport.id ? updatedReport : r))
)
setSelectedReport(updatedReport)
}, [])
const handleReportDeleted = useCallback((reportId: string) => {
setReports((prev) => prev.filter((r) => r.id !== reportId))
if (selectedReport?.id === reportId) {
setSelectedReport(null)
}
}, [selectedReport])
const handleDuplicate = useCallback((content: string) => {
setDuplicateContent(content)
// Open composer als deze collapsed is
if (isComposerCollapsed) {
setIsComposerCollapsed(false)
}
}, [isComposerCollapsed])
const handleTypeSelect = useCallback((type: ReportType) => {
setSelectedType(type)
}, [])
const toggleComposer = useCallback(() => {
setIsComposerCollapsed((prev) => !prev)
}, [])
const PanelGroupComponent = panelsLib?.PanelGroup
const PanelComponent = panelsLib?.Panel
const PanelResizeHandleComponent = panelsLib?.PanelResizeHandle
const composerPanel = (
<div className="h-full flex flex-col">
<div className="px-4 py-3 border-b border-slate-100 shrink-0 bg-slate-50">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold text-slate-700">Nieuwe rapportage</h2>
{PanelGroupComponent ? (
<button
type="button"
onClick={toggleComposer}
className="p-1.5 text-slate-400 hover:text-slate-600 hover:bg-slate-200 rounded-md transition-colors"
aria-label="Sluit editor"
>
<ChevronRight className="h-4 w-4" />
</button>
) : (
<span className="text-xs text-slate-400">Layout optimalisatie laden</span>
)}
</div>
<QuickActions onSelectType={handleTypeSelect} selectedType={selectedType} />
</div>
<div className="flex-1 overflow-y-auto p-4">
<ReportComposer
patientId={patientId}
patientName={patientName}
selectedReport={selectedReport}
onReportCreated={handleReportCreated}
initialContent={duplicateContent}
onInitialContentConsumed={() => setDuplicateContent(null)}
/>
</div>
</div>
)
return (
<div className="h-screen flex flex-col bg-slate-50">
<div className="flex-1 overflow-hidden">
{PanelGroupComponent && PanelComponent && PanelResizeHandleComponent ? (
<PanelGroupComponent direction="horizontal" className="h-full">
<PanelComponent
defaultSize={isComposerCollapsed ? 100 : 60}
minSize={40}
className="relative"
>
<ReportTimeline
reports={reports}
onSelectReport={handleSelectReport}
activeReportId={selectedReport?.id}
/>
</PanelComponent>
{!isComposerCollapsed && (
<PanelResizeHandleComponent className="relative w-1 bg-slate-200 hover:bg-emerald-400 transition-colors group">
<div className="absolute inset-y-0 left-1/2 -translate-x-1/2 w-1 group-hover:w-1.5 bg-slate-300 group-hover:bg-emerald-500 transition-all" />
</PanelResizeHandleComponent>
)}
{!isComposerCollapsed && (
<PanelComponent
defaultSize={40}
minSize={25}
maxSize={60}
className="relative bg-white border-l border-slate-200"
>
{composerPanel}
</PanelComponent>
)}
{isComposerCollapsed && (
<div className="absolute top-1/2 right-0 -translate-y-1/2 z-20">
<button
type="button"
onClick={toggleComposer}
className={cn(
'flex items-center gap-2 px-3 py-6 rounded-l-lg',
'bg-emerald-500 text-white shadow-lg',
'hover:bg-emerald-600 transition-all',
'border border-r-0 border-emerald-600'
)}
aria-label="Open editor"
>
<ChevronLeft className="h-5 w-5" />
<span className="text-sm font-medium writing-mode-vertical-rl rotate-180">
Nieuwe rapportage
</span>
</button>
</div>
)}
</PanelGroupComponent>
) : (
<div className="h-full flex flex-col lg:flex-row">
<div className="flex-1 overflow-y-auto border-b border-slate-200">
<ReportTimeline
reports={reports}
onSelectReport={handleSelectReport}
activeReportId={selectedReport?.id}
/>
</div>
<div className="lg:w-[420px] border-t border-slate-200 lg:border-t-0 lg:border-l lg:border-slate-200 bg-white">
{composerPanel}
</div>
</div>
)}
</div>
{isModalOpen && selectedReport && (
<ReportViewEditModal
report={selectedReport}
isOpen={isModalOpen}
onClose={handleCloseModal}
patientId={patientId}
onReportUpdated={handleReportUpdated}
onReportDeleted={handleReportDeleted}
onDuplicate={handleDuplicate}
/>
)}
</div>
)
}

View File

@@ -1,18 +1,37 @@
'use client';
import { useMemo, useState, useEffect } from 'react';
import { useMemo, useState, useEffect, useRef, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import dynamic from 'next/dynamic';
import { Button } from '@/components/ui/button';
import { SpeechRecorder } from '@/components/speech-recorder';
import { toast } from '@/hooks/use-toast';
import type { ClassificationResult, Report } from '@/lib/types/report';
import { createReport } from '../actions';
import { cn } from '@/lib/utils';
const SpeechRecorderStreaming = dynamic(
() => import('@/components/speech-recorder-streaming').then((m) => m.SpeechRecorderStreaming),
{ ssr: false, loading: () => <RecorderSkeleton /> }
);
function RecorderSkeleton() {
return (
<div className="rounded-lg border border-slate-200 bg-white p-4 animate-pulse">
<div className="h-4 w-1/2 rounded bg-slate-100 mb-2" />
<div className="h-10 rounded bg-slate-100" />
</div>
);
}
interface ReportComposerProps {
patientId: string;
patientName: string;
selectedReport?: Report | null;
onReportCreated?: (report: Report) => void;
/** Initial content voor de editor (bijv. van duplicate) */
initialContent?: string | null;
/** Callback wanneer initialContent is verwerkt */
onInitialContentConsumed?: () => void;
}
export function ReportComposer({
@@ -20,8 +39,11 @@ export function ReportComposer({
patientName,
selectedReport,
onReportCreated,
initialContent,
onInitialContentConsumed,
}: ReportComposerProps) {
const router = useRouter();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [content, setContent] = useState('');
const [classification, setClassification] = useState<ClassificationResult | null>(null);
const [selectedType, setSelectedType] = useState<'behandeladvies' | 'vrije_notitie'>('vrije_notitie');
@@ -29,8 +51,44 @@ export function ReportComposer({
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 draftStorageKey = useMemo(() => `rapportage-draft-${patientId}`, [patientId]);
// 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;
@@ -170,16 +228,8 @@ export function ReportComposer({
<section
id="rapportage-composer"
tabIndex={-1}
className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm focus:outline-none"
className="focus:outline-none"
>
<div className="mb-4">
<p className="text-sm uppercase tracking-wide text-slate-500">Nieuwe rapportage</p>
<h2 className="text-xl font-semibold text-slate-900">Voor {patientName}</h2>
<p className="text-sm text-slate-500">
Schrijf vanuit tekst of spraak en gebruik AI voor typebepaling.
</p>
</div>
{referenceSnippet && (
<div className="mb-4 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">
@@ -205,22 +255,41 @@ export function ReportComposer({
<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="w-full min-h-[200px] rounded-xl border border-slate-200 bg-white p-4 text-sm text-slate-900 shadow-inner focus:border-teal-500 focus:outline-none"
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'
)}
/>
<div className="flex justify-between text-xs text-slate-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>
<SpeechRecorder
<SpeechRecorderStreaming
disabled={isSaving || isAnalyzing}
onTranscript={(text) =>
setContent((prev) => (prev ? `${prev}\n${text}` : 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">

View File

@@ -0,0 +1,547 @@
'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
import { X, Pencil, Copy, Trash2, Save, Loader2 } from 'lucide-react'
import { format, formatDistanceToNow } from 'date-fns'
import { nl } from 'date-fns/locale'
import { cn } from '@/lib/utils'
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'
// ─────────────────────────────────────────────────────────────────────────────
// Types
// ─────────────────────────────────────────────────────────────────────────────
export interface ReportViewEditModalProps {
/** Het rapport om te bekijken/bewerken */
report: Report | null
/** Of de modal open is */
isOpen: boolean
/** Callback om modal te sluiten */
onClose: () => void
/** Patient ID voor API calls */
patientId: string
/** Callback na succesvolle update */
onReportUpdated?: (report: Report) => void
/** Callback na verwijdering */
onReportDeleted?: (reportId: string) => void
/** Callback voor dupliceren */
onDuplicate?: (content: string) => void
}
type ModalMode = 'read' | 'edit'
// ─────────────────────────────────────────────────────────────────────────────
// Unsaved Changes Dialog
// ─────────────────────────────────────────────────────────────────────────────
function UnsavedChangesDialog({
isOpen,
onSaveAndClose,
onDiscardAndClose,
onCancel,
isSaving,
}: {
isOpen: boolean
onSaveAndClose: () => void
onDiscardAndClose: () => void
onCancel: () => void
isSaving: boolean
}) {
if (!isOpen) return null
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50">
<div className="bg-white rounded-xl shadow-2xl p-6 max-w-md mx-4 animate-in fade-in zoom-in-95 duration-200">
<h3 className="text-lg font-semibold text-slate-900">
Niet opgeslagen wijzigingen
</h3>
<p className="mt-2 text-sm text-slate-600">
Je hebt wijzigingen die nog niet zijn opgeslagen. Wat wil je doen?
</p>
<div className="mt-4 flex flex-col gap-2">
<button
type="button"
onClick={onSaveAndClose}
disabled={isSaving}
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-emerald-600 text-white rounded-lg font-medium hover:bg-emerald-700 disabled:opacity-50"
>
{isSaving ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Opslaan...
</>
) : (
<>
<Save className="h-4 w-4" />
Opslaan en sluiten
</>
)}
</button>
<button
type="button"
onClick={onDiscardAndClose}
disabled={isSaving}
className="w-full flex items-center justify-center gap-2 px-4 py-2 border border-red-200 text-red-600 rounded-lg font-medium hover:bg-red-50 disabled:opacity-50"
>
<Trash2 className="h-4 w-4" />
Wijzigingen verwijderen
</button>
<button
type="button"
onClick={onCancel}
disabled={isSaving}
className="w-full px-4 py-2 text-slate-600 rounded-lg font-medium hover:bg-slate-100 disabled:opacity-50"
>
Terug naar bewerken
</button>
</div>
</div>
</div>
)
}
// ─────────────────────────────────────────────────────────────────────────────
// Delete Confirmation Dialog
// ─────────────────────────────────────────────────────────────────────────────
function DeleteConfirmDialog({
isOpen,
onConfirm,
onCancel,
isDeleting,
}: {
isOpen: boolean
onConfirm: () => void
onCancel: () => void
isDeleting: boolean
}) {
if (!isOpen) return null
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50">
<div className="bg-white rounded-xl shadow-2xl p-6 max-w-md mx-4 animate-in fade-in zoom-in-95 duration-200">
<h3 className="text-lg font-semibold text-slate-900">
Rapportage verwijderen?
</h3>
<p className="mt-2 text-sm text-slate-600">
Weet je zeker dat je deze rapportage wilt verwijderen? Dit kan niet ongedaan worden gemaakt.
</p>
<div className="mt-4 flex gap-2">
<button
type="button"
onClick={onCancel}
disabled={isDeleting}
className="flex-1 px-4 py-2 border border-slate-200 text-slate-700 rounded-lg font-medium hover:bg-slate-50 disabled:opacity-50"
>
Annuleren
</button>
<button
type="button"
onClick={onConfirm}
disabled={isDeleting}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 disabled:opacity-50"
>
{isDeleting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Verwijderen...
</>
) : (
<>
<Trash2 className="h-4 w-4" />
Verwijderen
</>
)}
</button>
</div>
</div>
</div>
)
}
// ─────────────────────────────────────────────────────────────────────────────
// Main Modal Component
// ─────────────────────────────────────────────────────────────────────────────
export function ReportViewEditModal({
report,
isOpen,
onClose,
patientId,
onReportUpdated,
onReportDeleted,
onDuplicate,
}: ReportViewEditModalProps) {
// State
const [mode, setMode] = useState<ModalMode>('read')
const [content, setContent] = useState('')
const [originalContent, setOriginalContent] = useState('')
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
useEffect(() => {
if (report) {
setContent(report.content)
setOriginalContent(report.content)
setMode('read')
}
}, [report])
// Reset bij sluiten
useEffect(() => {
if (!isOpen) {
setMode('read')
setShowUnsavedDialog(false)
setShowDeleteDialog(false)
}
}, [isOpen])
// Check for unsaved changes
const hasUnsavedChanges = content !== originalContent
// Keyboard handler (Escape)
// Handlers
const handleClose = useCallback(() => {
if (mode === 'edit' && hasUnsavedChanges) {
setShowUnsavedDialog(true)
} else {
onClose()
}
}, [mode, hasUnsavedChanges, onClose])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
handleClose()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isOpen, handleClose])
const handleEdit = useCallback(() => {
setMode('edit')
// Focus textarea en zet cursor aan einde
setTimeout(() => {
if (textareaRef.current) {
textareaRef.current.focus()
const length = textareaRef.current.value.length
textareaRef.current.setSelectionRange(length, length)
}
}, 100)
}, [])
const handleCancelEdit = useCallback(() => {
if (hasUnsavedChanges) {
setShowUnsavedDialog(true)
} else {
setContent(originalContent)
setMode('read')
}
}, [hasUnsavedChanges, originalContent])
const handleSave = useCallback(async () => {
if (!report) return
setIsSaving(true)
try {
const updated = await updateReport(patientId, report.id, { content })
setOriginalContent(content)
onReportUpdated?.(updated)
toast({
title: 'Wijzigingen opgeslagen',
description: 'De rapportage is bijgewerkt.',
})
} catch (error) {
console.error('Failed to save:', error)
toast({
variant: 'destructive',
title: 'Opslaan mislukt',
description: error instanceof Error ? error.message : 'Probeer het opnieuw.',
})
} finally {
setIsSaving(false)
}
}, [report, patientId, content, onReportUpdated])
const handleSaveAndClose = useCallback(async () => {
await handleSave()
setShowUnsavedDialog(false)
onClose()
}, [handleSave, onClose])
const handleDiscardAndClose = useCallback(() => {
setContent(originalContent)
setShowUnsavedDialog(false)
onClose()
}, [originalContent, onClose])
const handleDelete = useCallback(async () => {
if (!report) return
setIsDeleting(true)
try {
await deleteReport(patientId, report.id)
onReportDeleted?.(report.id)
toast({
title: 'Rapportage verwijderd',
description: 'De rapportage is verwijderd.',
})
onClose()
} catch (error) {
console.error('Failed to delete:', error)
toast({
variant: 'destructive',
title: 'Verwijderen mislukt',
description: error instanceof Error ? error.message : 'Probeer het opnieuw.',
})
} finally {
setIsDeleting(false)
setShowDeleteDialog(false)
}
}, [report, patientId, onReportDeleted, onClose])
const handleDuplicate = useCallback(() => {
onDuplicate?.(content)
onClose()
toast({
title: 'Gekopieerd',
description: 'Inhoud is gekopieerd naar de editor.',
})
}, [content, onDuplicate, onClose])
const handleTranscript = useCallback((transcript: string) => {
setContent((prev) => (prev ? `${prev} ${transcript}` : transcript))
}, [])
const handleRecordingStart = useCallback(() => {
setIsStreaming(true)
if (textareaRef.current) {
const length = textareaRef.current.value.length
textareaRef.current.setSelectionRange(length, length)
textareaRef.current.scrollTop = textareaRef.current.scrollHeight
}
}, [])
const handleRecordingStop = useCallback(() => {
setIsStreaming(false)
}, [])
// Don't render if not open or no report
if (!isOpen || !report) return null
const createdAt = report.created_at ? new Date(report.created_at) : null
const dateFormatted = createdAt
? format(createdAt, "d MMMM yyyy 'om' HH:mm", { locale: nl })
: ''
const relativeTime = createdAt
? formatDistanceToNow(createdAt, { addSuffix: true, locale: nl })
: ''
const TYPE_LABELS: Record<string, string> = {
behandeladvies: 'Behandeladvies',
vrije_notitie: 'Vrije notitie',
intake_verslag: 'Intake verslag',
behandelplan: 'Behandelplan',
}
return (
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-40 bg-black/30 animate-in fade-in duration-200"
onClick={handleClose}
aria-hidden="true"
/>
{/* Modal */}
<div className="fixed inset-0 z-50 flex items-start justify-center p-4 pt-[10vh] overflow-y-auto">
<div
className={cn(
'bg-white rounded-2xl shadow-2xl w-full max-w-3xl',
'animate-in fade-in slide-in-from-bottom-4 duration-300'
)}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-start justify-between p-6 border-b border-slate-200">
<div>
<h2 className="text-xl font-semibold text-slate-900">
{TYPE_LABELS[report.type] || report.type}
{mode === 'edit' && (
<span className="ml-2 text-sm font-normal text-emerald-600">
(bewerken)
</span>
)}
</h2>
<p className="mt-1 text-sm text-slate-500">
{dateFormatted}
{relativeTime && <span className="ml-2 text-slate-400">({relativeTime})</span>}
</p>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
{mode === 'read' ? (
<>
<button
type="button"
onClick={handleEdit}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-slate-700 bg-white border border-slate-200 rounded-lg hover:bg-slate-50"
>
<Pencil className="h-4 w-4" />
Bewerken
</button>
{onDuplicate && (
<button
type="button"
onClick={handleDuplicate}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-slate-700 bg-white border border-slate-200 rounded-lg hover:bg-slate-50"
>
<Copy className="h-4 w-4" />
Dupliceer
</button>
)}
<button
type="button"
onClick={() => setShowDeleteDialog(true)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-red-600 bg-white border border-red-200 rounded-lg hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</button>
</>
) : (
<>
<button
type="button"
onClick={handleSave}
disabled={isSaving || !hasUnsavedChanges}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-white bg-emerald-600 rounded-lg hover:bg-emerald-700 disabled:opacity-50"
>
{isSaving ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Opslaan...
</>
) : (
<>
<Save className="h-4 w-4" />
Opslaan
</>
)}
</button>
<button
type="button"
onClick={handleCancelEdit}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-slate-700 bg-white border border-slate-200 rounded-lg hover:bg-slate-50"
>
Annuleren
</button>
</>
)}
<button
type="button"
onClick={handleClose}
className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg"
aria-label="Sluiten"
>
<X className="h-5 w-5" />
</button>
</div>
</div>
{/* Speech Recorder (only in edit mode) */}
{mode === 'edit' && (
<div className="p-6 border-b border-slate-100 bg-slate-50">
<SpeechRecorderStreaming
onTranscript={handleTranscript}
onRecordingStart={handleRecordingStart}
onRecordingStop={handleRecordingStop}
telemetryContext={{
context: 'report_modal',
patientId,
reportId: report.id,
}}
/>
</div>
)}
{/* Content */}
<div className="p-6">
{mode === 'read' ? (
<div className="prose prose-slate max-w-none">
<p className="whitespace-pre-wrap text-slate-700 leading-relaxed">
{content}
</p>
</div>
) : (
<textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
className={cn(
'w-full min-h-[300px] p-4 text-sm text-slate-900 rounded-xl border bg-white',
'focus:outline-none transition-all duration-200',
isStreaming
? 'border-emerald-500 border-2 shadow-emerald-500/20 shadow-md'
: 'border-slate-200 focus:border-emerald-300'
)}
placeholder="Bewerk de rapportage of dicteer met spraak..."
/>
)}
</div>
{/* Footer with metadata */}
{report.ai_reasoning && (
<div className="px-6 pb-6">
<div className="rounded-lg bg-slate-50 p-4 text-sm">
<p className="font-medium text-slate-700">AI toelichting</p>
<p className="mt-1 text-slate-600">{report.ai_reasoning}</p>
{report.ai_confidence !== null && (
<p className="mt-2 text-xs text-slate-500">
Zekerheid: {Math.round(report.ai_confidence * 100)}%
</p>
)}
</div>
</div>
)}
{/* Unsaved indicator */}
{mode === 'edit' && hasUnsavedChanges && (
<div className="px-6 pb-4">
<p className="text-xs text-amber-600 flex items-center gap-1">
<span className="w-2 h-2 rounded-full bg-amber-500" />
Niet opgeslagen wijzigingen
</p>
</div>
)}
</div>
</div>
{/* Dialogs */}
<UnsavedChangesDialog
isOpen={showUnsavedDialog}
onSaveAndClose={handleSaveAndClose}
onDiscardAndClose={handleDiscardAndClose}
onCancel={() => setShowUnsavedDialog(false)}
isSaving={isSaving}
/>
<DeleteConfirmDialog
isOpen={showDeleteDialog}
onConfirm={handleDelete}
onCancel={() => setShowDeleteDialog(false)}
isDeleting={isDeleting}
/>
</>
)
}