feat: add speech telemetry and bundle guard
This commit is contained in:
@@ -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 }))}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user