feat: add speech telemetry and bundle guard
This commit is contained in:
56
app/api/telemetry/speech/route.ts
Normal file
56
app/api/telemetry/speech/route.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { createClient } from '@/lib/auth/server'
|
||||||
|
|
||||||
|
const payloadSchema = z.object({
|
||||||
|
action: z.enum(['start', 'stop', 'final']),
|
||||||
|
context: z.string().min(1),
|
||||||
|
patientId: z.string().min(1).optional(),
|
||||||
|
intakeId: z.string().min(1).optional(),
|
||||||
|
reportId: z.string().min(1).optional(),
|
||||||
|
metadata: z.record(z.string(), z.any()).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const json = await request.json()
|
||||||
|
const payload = payloadSchema.safeParse(json)
|
||||||
|
|
||||||
|
if (!payload.success) {
|
||||||
|
return NextResponse.json({ error: 'Invalid payload' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = await createClient()
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
error: authError,
|
||||||
|
} = await supabase.auth.getUser()
|
||||||
|
|
||||||
|
if (authError) throw authError
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { action, context, patientId, intakeId, reportId, metadata } = payload.data
|
||||||
|
|
||||||
|
const { error } = await supabase.from('speech_usage_events').insert({
|
||||||
|
action,
|
||||||
|
context,
|
||||||
|
user_id: user.id,
|
||||||
|
patient_id: patientId ?? null,
|
||||||
|
intake_id: intakeId ?? null,
|
||||||
|
report_id: reportId ?? null,
|
||||||
|
metadata: metadata ?? {},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Failed to persist speech telemetry', error)
|
||||||
|
return NextResponse.json({ error: 'Database error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Speech telemetry error', error)
|
||||||
|
return NextResponse.json({ error: 'Server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,38 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useTransition } from 'react';
|
import { useState, useTransition, useCallback } from 'react';
|
||||||
import { saveTreatmentAdvice } from '../../actions';
|
import { saveTreatmentAdvice } from '../../actions';
|
||||||
import { Loader2, Calendar, UserCircle, ClipboardList, Share2, CheckCircle2 } from 'lucide-react';
|
import { Loader2, Calendar, UserCircle, ClipboardList, Share2, CheckCircle2 } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { RichTextEditor } from '@/components/rich-text-editor';
|
import dynamic from 'next/dynamic';
|
||||||
import { SpeechRecorder } from '@/components/speech-recorder';
|
|
||||||
|
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 {
|
interface AdviceData {
|
||||||
advice?: string;
|
advice?: string;
|
||||||
@@ -52,8 +79,10 @@ export function TreatmentAdviceForm({ patientId, intakeId, initialData, initialD
|
|||||||
const [finalize, setFinalize] = useState(Boolean(initialData.outcome));
|
const [finalize, setFinalize] = useState(Boolean(initialData.outcome));
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isPending, startTransition] = useTransition();
|
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;
|
if (!text) return;
|
||||||
const sanitized = text
|
const sanitized = text
|
||||||
.split('\n')
|
.split('\n')
|
||||||
@@ -67,7 +96,16 @@ export function TreatmentAdviceForm({ patientId, intakeId, initialData, initialD
|
|||||||
)
|
)
|
||||||
.join('');
|
.join('');
|
||||||
setForm((prev) => ({ ...prev, advice: `${prev.advice || ''}${sanitized}` }));
|
setForm((prev) => ({ ...prev, advice: `${prev.advice || ''}${sanitized}` }));
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
|
const handleRecordingStart = useCallback(() => {
|
||||||
|
setIsStreaming(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRecordingStop = useCallback(() => {
|
||||||
|
setIsStreaming(false);
|
||||||
|
setInterimText('');
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (!form.advice) {
|
if (!form.advice) {
|
||||||
@@ -217,7 +255,22 @@ export function TreatmentAdviceForm({ patientId, intakeId, initialData, initialD
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<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
|
<RichTextEditor
|
||||||
value={form.advice}
|
value={form.advice}
|
||||||
onChange={(html) => setForm((prev) => ({ ...prev, advice: html }))}
|
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 Link from 'next/link';
|
||||||
import { ChevronLeft } from 'lucide-react';
|
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 {
|
interface NewIntakePageProps {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
}
|
}
|
||||||
@@ -31,4 +50,3 @@ export default async function NewIntakePage({ params }: NewIntakePageProps) {
|
|||||||
</div>
|
</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';
|
'use client';
|
||||||
|
|
||||||
import { useMemo, useState, useEffect } from 'react';
|
import { useMemo, useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { SpeechRecorder } from '@/components/speech-recorder';
|
|
||||||
import { toast } from '@/hooks/use-toast';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import type { ClassificationResult, Report } from '@/lib/types/report';
|
import type { ClassificationResult, Report } from '@/lib/types/report';
|
||||||
import { createReport } from '../actions';
|
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 {
|
interface ReportComposerProps {
|
||||||
patientId: string;
|
patientId: string;
|
||||||
patientName: string;
|
patientName: string;
|
||||||
selectedReport?: Report | null;
|
selectedReport?: Report | null;
|
||||||
onReportCreated?: (report: Report) => void;
|
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({
|
export function ReportComposer({
|
||||||
@@ -20,8 +39,11 @@ export function ReportComposer({
|
|||||||
patientName,
|
patientName,
|
||||||
selectedReport,
|
selectedReport,
|
||||||
onReportCreated,
|
onReportCreated,
|
||||||
|
initialContent,
|
||||||
|
onInitialContentConsumed,
|
||||||
}: ReportComposerProps) {
|
}: ReportComposerProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(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 [selectedType, setSelectedType] = useState<'behandeladvies' | 'vrije_notitie'>('vrije_notitie');
|
||||||
@@ -29,8 +51,44 @@ export function ReportComposer({
|
|||||||
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 [interimText, setInterimText] = useState('');
|
||||||
const draftStorageKey = useMemo(() => `rapportage-draft-${patientId}`, [patientId]);
|
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 characterCount = content.length;
|
||||||
const contentInvalid = characterCount < 20 || characterCount > 5000;
|
const contentInvalid = characterCount < 20 || characterCount > 5000;
|
||||||
|
|
||||||
@@ -170,16 +228,8 @@ export function ReportComposer({
|
|||||||
<section
|
<section
|
||||||
id="rapportage-composer"
|
id="rapportage-composer"
|
||||||
tabIndex={-1}
|
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 && (
|
{referenceSnippet && (
|
||||||
<div className="mb-4 rounded-xl border border-slate-100 bg-slate-50 p-4">
|
<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">
|
<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 className="flex flex-col gap-4">
|
||||||
<div>
|
<div>
|
||||||
<textarea
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
value={content}
|
value={content}
|
||||||
onChange={(e) => setContent(e.target.value)}
|
onChange={(e) => setContent(e.target.value)}
|
||||||
placeholder="Beschrijf wat je wilt vastleggen..."
|
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>
|
<span>{characterCount} / 5000 karakters</span>
|
||||||
{contentInvalid && <span>Minimaal 20 karakters</span>}
|
{contentInvalid && <span>Minimaal 20 karakters</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SpeechRecorder
|
<SpeechRecorderStreaming
|
||||||
disabled={isSaving || isAnalyzing}
|
disabled={isSaving || isAnalyzing}
|
||||||
onTranscript={(text) =>
|
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">
|
<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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
255
app/login/components/login-form.tsx
Normal file
255
app/login/components/login-form.tsx
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { Eye, EyeOff } from 'lucide-react'
|
||||||
|
import { loginWithPassword, signUpWithPassword } from '@/lib/auth/client'
|
||||||
|
|
||||||
|
export function LoginForm() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [mode, setMode] = useState<'login' | 'signup'>('login')
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setLoading(true)
|
||||||
|
setMessage(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (mode === 'signup') {
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
throw new Error('Wachtwoorden komen niet overeen')
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await signUpWithPassword(email, password)
|
||||||
|
if (result.session) {
|
||||||
|
setMessage({ type: 'success', text: 'Account aangemaakt! Je wordt doorgestuurd...' })
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push('/epd/patients')
|
||||||
|
}, 1000)
|
||||||
|
} else {
|
||||||
|
setMessage({
|
||||||
|
type: 'success',
|
||||||
|
text: 'Check je inbox voor een verificatie link om je account te activeren.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await loginWithPassword(email, password)
|
||||||
|
setMessage({ type: 'success', text: 'Ingelogd! Je wordt doorgestuurd...' })
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push('/epd/clients')
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.code === 'user_already_registered' && error.data?.session) {
|
||||||
|
setMessage({ type: 'success', text: 'Dit emailadres bestaat al. Je bent nu ingelogd!' })
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push('/epd/patients')
|
||||||
|
}, 1000)
|
||||||
|
} else if (error.code === 'user_already_registered') {
|
||||||
|
setMessage({
|
||||||
|
type: 'error',
|
||||||
|
text: error.message || 'Dit emailadres is al geregistreerd. Probeer in te loggen.'
|
||||||
|
})
|
||||||
|
setTimeout(() => {
|
||||||
|
setMode('login')
|
||||||
|
setPassword('')
|
||||||
|
setConfirmPassword('')
|
||||||
|
}, 2000)
|
||||||
|
} else if (error.code === 'invalid_credentials' && mode === 'login') {
|
||||||
|
setMessage({ type: 'error', text: error.message || 'Email of wachtwoord is onjuist.' })
|
||||||
|
} else if (error.code === 'email_not_confirmed') {
|
||||||
|
setMessage({ type: 'error', text: error.message || 'Je email is nog niet geverifieerd. Check je inbox.' })
|
||||||
|
} else {
|
||||||
|
setMessage({ type: 'error', text: error.message || 'Er ging iets mis. Probeer opnieuw.' })
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleQuickDemoLogin = async () => {
|
||||||
|
setMode('login')
|
||||||
|
setEmail('demo@mini-ecd.demo')
|
||||||
|
setPassword('Demo2024!')
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
await loginWithPassword('demo@mini-ecd.demo', 'Demo2024!')
|
||||||
|
router.push('/epd/clients')
|
||||||
|
} catch (error: any) {
|
||||||
|
setMessage({ type: 'error', text: 'Demo login mislukt. Probeer handmatig in te loggen.' })
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="md:w-2/5 bg-white p-8 md:p-12 flex flex-col justify-center">
|
||||||
|
<div className="w-full max-w-md mx-auto">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h2 className="text-2xl md:text-3xl font-bold text-slate-900 mb-2">
|
||||||
|
{mode === 'login' ? 'Welkom terug' : 'Maak een account'}
|
||||||
|
</h2>
|
||||||
|
<p className="text-slate-600">
|
||||||
|
{mode === 'login'
|
||||||
|
? 'Log in om toegang te krijgen tot het EPD'
|
||||||
|
: 'Start vandaag nog met snellere rapportages'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex bg-slate-100 p-1 rounded-lg mb-6">
|
||||||
|
<button
|
||||||
|
className={`flex-1 py-2 text-sm font-medium rounded-md transition-all ${
|
||||||
|
mode === 'login' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-900'
|
||||||
|
}`}
|
||||||
|
onClick={() => setMode('login')}
|
||||||
|
>
|
||||||
|
Inloggen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`flex-1 py-2 text-sm font-medium rounded-md transition-all ${
|
||||||
|
mode === 'signup' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-900'
|
||||||
|
}`}
|
||||||
|
onClick={() => setMode('signup')}
|
||||||
|
>
|
||||||
|
Registreren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<div
|
||||||
|
className={`mb-6 p-4 rounded-lg ${
|
||||||
|
message.type === 'success'
|
||||||
|
? 'bg-teal-50 text-teal-800 border border-teal-200'
|
||||||
|
: 'bg-red-50 text-red-800 border border-red-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p className="text-sm font-medium">{message.text}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium text-slate-700 mb-1">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="jouw@email.nl"
|
||||||
|
required
|
||||||
|
className="w-full px-4 py-2.5 border border-slate-300 rounded-lg focus:ring-2 focus:ring-slate-400 focus:border-slate-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-slate-700 mb-1">
|
||||||
|
Wachtwoord
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
className="w-full px-4 py-2.5 pr-11 border border-slate-300 rounded-lg focus:ring-2 focus:ring-slate-400 focus:border-slate-400"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 transition-colors"
|
||||||
|
aria-label={showPassword ? 'Verberg wachtwoord' : 'Toon wachtwoord'}
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{mode === 'login' && (
|
||||||
|
<div className="mt-1 text-right">
|
||||||
|
<Link href="/reset-password" className="text-xs text-slate-500 hover:text-teal-600">
|
||||||
|
Wachtwoord vergeten?
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === 'signup' && (
|
||||||
|
<div>
|
||||||
|
<label htmlFor="confirmPassword" className="block text-sm font-medium text-slate-700 mb-1">
|
||||||
|
Bevestig Wachtwoord
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
id="confirmPassword"
|
||||||
|
type={showConfirmPassword ? 'text' : 'password'}
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
className="w-full px-4 py-2.5 pr-11 border border-slate-300 rounded-lg focus:ring-2 focus:ring-slate-400 focus:border-slate-400"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 transition-colors"
|
||||||
|
aria-label={showConfirmPassword ? 'Verberg wachtwoord' : 'Toon wachtwoord'}
|
||||||
|
>
|
||||||
|
{showConfirmPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-teal-600 hover:bg-teal-700 text-white font-medium py-2.5 px-4 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{loading ? 'Laden...' : mode === 'login' ? 'Inloggen' : 'Registreren'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="relative my-6">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<div className="w-full border-t border-slate-300" />
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center text-sm">
|
||||||
|
<span className="px-2 bg-white text-slate-500">of</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleQuickDemoLogin}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-amber-50 hover:bg-amber-100 text-amber-800 text-sm font-medium py-2 px-4 rounded-lg border border-amber-200 transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<span>⚡</span>
|
||||||
|
Demo Account Proberen
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p className="text-center text-xs text-slate-500 mt-8">
|
||||||
|
Build in Public door{' '}
|
||||||
|
<a
|
||||||
|
href="https://ikbenlit.nl"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-teal-600 hover:text-teal-700 font-medium"
|
||||||
|
>
|
||||||
|
AI Speedrun
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,13 +1,7 @@
|
|||||||
'use client'
|
import { Brain, Zap, Target, Clock } from 'lucide-react'
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { useRouter } from 'next/navigation'
|
|
||||||
import { loginWithPassword, signUpWithPassword } from '@/lib/auth/client'
|
|
||||||
import { Brain, Zap, Target, Clock, Eye, EyeOff } from 'lucide-react'
|
|
||||||
import { BentoGrid, BentoCard } from '@/components/ui/bento-grid'
|
import { BentoGrid, BentoCard } from '@/components/ui/bento-grid'
|
||||||
import Link from 'next/link'
|
import { LoginForm } from './components/login-form'
|
||||||
|
|
||||||
// Bento grid features with different sizes for visual interest
|
|
||||||
const bentoFeatures = [
|
const bentoFeatures = [
|
||||||
{
|
{
|
||||||
name: 'AI Intake Samenvatting',
|
name: 'AI Intake Samenvatting',
|
||||||
@@ -56,144 +50,10 @@ const bentoFeatures = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const router = useRouter()
|
|
||||||
const [mode, setMode] = useState<'login' | 'signup'>('login')
|
|
||||||
const [email, setEmail] = useState('')
|
|
||||||
const [password, setPassword] = useState('')
|
|
||||||
const [confirmPassword, setConfirmPassword] = useState('') // Only for signup
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [showPassword, setShowPassword] = useState(false)
|
|
||||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
|
|
||||||
const [message, setMessage] = useState<{
|
|
||||||
type: 'success' | 'error'
|
|
||||||
text: string
|
|
||||||
} | null>(null)
|
|
||||||
|
|
||||||
// Handle form submission
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
setLoading(true)
|
|
||||||
setMessage(null)
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (mode === 'signup') {
|
|
||||||
// Signup Flow
|
|
||||||
if (password !== confirmPassword) {
|
|
||||||
throw new Error('Wachtwoorden komen niet overeen')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sign up
|
|
||||||
const result = await signUpWithPassword(email, password)
|
|
||||||
|
|
||||||
// Check if user got a session immediately (email confirmation disabled)
|
|
||||||
if (result.session) {
|
|
||||||
// User is logged in immediately
|
|
||||||
setMessage({
|
|
||||||
type: 'success',
|
|
||||||
text: 'Account aangemaakt! Je wordt doorgestuurd...'
|
|
||||||
})
|
|
||||||
setTimeout(() => {
|
|
||||||
router.push('/epd/patients')
|
|
||||||
}, 1000)
|
|
||||||
} else {
|
|
||||||
// Email confirmation required - user needs to check inbox
|
|
||||||
setMessage({
|
|
||||||
type: 'success',
|
|
||||||
text: 'Check je inbox voor een verificatie link om je account te activeren.'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
} else {
|
|
||||||
// Login Flow
|
|
||||||
await loginWithPassword(email, password)
|
|
||||||
|
|
||||||
setMessage({
|
|
||||||
type: 'success',
|
|
||||||
text: 'Ingelogd! Je wordt doorgestuurd...'
|
|
||||||
})
|
|
||||||
|
|
||||||
// Redirect to EPD
|
|
||||||
setTimeout(() => {
|
|
||||||
router.push('/epd/clients')
|
|
||||||
}, 1000)
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
// Check for duplicate email error with auto-login
|
|
||||||
if (error.code === 'user_already_registered' && error.data?.session) {
|
|
||||||
// User was auto-logged in (client-side detection succeeded)
|
|
||||||
setMessage({
|
|
||||||
type: 'success',
|
|
||||||
text: 'Dit emailadres bestaat al. Je bent nu ingelogd!'
|
|
||||||
})
|
|
||||||
setTimeout(() => {
|
|
||||||
router.push('/epd/patients')
|
|
||||||
}, 1000)
|
|
||||||
}
|
|
||||||
// Check for duplicate email error (from Auth Hook or client-side)
|
|
||||||
else if (error.code === 'user_already_registered') {
|
|
||||||
setMessage({
|
|
||||||
type: 'error',
|
|
||||||
text: error.message || 'Dit emailadres is al geregistreerd. Probeer in te loggen.'
|
|
||||||
})
|
|
||||||
// Switch to login mode after 2 seconds
|
|
||||||
setTimeout(() => {
|
|
||||||
setMode('login')
|
|
||||||
setPassword('')
|
|
||||||
setConfirmPassword('')
|
|
||||||
}, 2000)
|
|
||||||
}
|
|
||||||
// Invalid credentials during login - suggest signup
|
|
||||||
else if (error.code === 'invalid_credentials' && mode === 'login') {
|
|
||||||
setMessage({
|
|
||||||
type: 'error',
|
|
||||||
text: error.message || 'Email of wachtwoord is onjuist.'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
// Email not confirmed
|
|
||||||
else if (error.code === 'email_not_confirmed') {
|
|
||||||
setMessage({
|
|
||||||
type: 'error',
|
|
||||||
text: error.message || 'Je email is nog niet geverifieerd. Check je inbox.'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
// Generic error handling
|
|
||||||
else {
|
|
||||||
setMessage({
|
|
||||||
type: 'error',
|
|
||||||
text: error.message || 'Er ging iets mis. Probeer opnieuw.'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Quick demo login
|
|
||||||
const handleQuickDemoLogin = async () => {
|
|
||||||
setMode('login')
|
|
||||||
setEmail('demo@mini-ecd.demo')
|
|
||||||
setPassword('Demo2024!')
|
|
||||||
|
|
||||||
// Auto-submit
|
|
||||||
setLoading(true)
|
|
||||||
try {
|
|
||||||
await loginWithPassword('demo@mini-ecd.demo', 'Demo2024!')
|
|
||||||
router.push('/epd/clients')
|
|
||||||
} catch (error: any) {
|
|
||||||
setMessage({
|
|
||||||
type: 'error',
|
|
||||||
text: 'Demo login mislukt. Probeer handmatig in te loggen.'
|
|
||||||
})
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col lg:flex-row">
|
<div className="min-h-screen flex flex-col lg:flex-row">
|
||||||
{/* Left Side - Bento Grid Showcase (60%) */}
|
|
||||||
<div className="lg:w-3/5 bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 p-6 md:p-8 lg:p-12 flex flex-col justify-center">
|
<div className="lg:w-3/5 bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 p-6 md:p-8 lg:p-12 flex flex-col justify-center">
|
||||||
<div className="max-w-5xl mx-auto w-full">
|
<div className="max-w-5xl mx-auto w-full">
|
||||||
{/* Header */}
|
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<a href="/" className="inline-block mb-6 text-slate-300 hover:text-white transition-colors">
|
<a href="/" className="inline-block mb-6 text-slate-300 hover:text-white transition-colors">
|
||||||
← Terug naar home
|
← Terug naar home
|
||||||
@@ -206,11 +66,10 @@ export default function LoginPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bento Grid */}
|
|
||||||
<BentoGrid className="mb-8">
|
<BentoGrid className="mb-8">
|
||||||
{bentoFeatures.map((feature, index) => (
|
{bentoFeatures.map((feature) => (
|
||||||
<BentoCard
|
<BentoCard
|
||||||
key={index}
|
key={feature.name}
|
||||||
name={feature.name}
|
name={feature.name}
|
||||||
description={feature.description}
|
description={feature.description}
|
||||||
Icon={feature.icon}
|
Icon={feature.icon}
|
||||||
@@ -222,7 +81,6 @@ export default function LoginPage() {
|
|||||||
))}
|
))}
|
||||||
</BentoGrid>
|
</BentoGrid>
|
||||||
|
|
||||||
{/* Stats Footer */}
|
|
||||||
<div className="grid grid-cols-3 gap-4 mt-8">
|
<div className="grid grid-cols-3 gap-4 mt-8">
|
||||||
<div className="text-center p-4 bg-white/5 rounded-lg border border-white/10">
|
<div className="text-center p-4 bg-white/5 rounded-lg border border-white/10">
|
||||||
<div className="text-2xl md:text-3xl font-bold text-amber-400">90%+</div>
|
<div className="text-2xl md:text-3xl font-bold text-amber-400">90%+</div>
|
||||||
@@ -240,202 +98,7 @@ export default function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Side - Login/Signup Form (40%) */}
|
<LoginForm />
|
||||||
<div className="md:w-2/5 bg-white p-8 md:p-12 flex flex-col justify-center">
|
|
||||||
<div className="w-full max-w-md mx-auto">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="text-center mb-8">
|
|
||||||
<h2 className="text-2xl md:text-3xl font-bold text-slate-900 mb-2">
|
|
||||||
{mode === 'login' ? 'Welkom terug' : 'Maak een account'}
|
|
||||||
</h2>
|
|
||||||
<p className="text-slate-600">
|
|
||||||
{mode === 'login'
|
|
||||||
? 'Log in om toegang te krijgen tot het EPD'
|
|
||||||
: 'Start vandaag nog met snellere rapportages'
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mode Toggle */}
|
|
||||||
<div className="flex bg-slate-100 p-1 rounded-lg mb-6">
|
|
||||||
<button
|
|
||||||
className={`flex-1 py-2 text-sm font-medium rounded-md transition-all ${
|
|
||||||
mode === 'login'
|
|
||||||
? 'bg-white text-slate-900 shadow-sm'
|
|
||||||
: 'text-slate-500 hover:text-slate-900'
|
|
||||||
}`}
|
|
||||||
onClick={() => setMode('login')}
|
|
||||||
>
|
|
||||||
Inloggen
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`flex-1 py-2 text-sm font-medium rounded-md transition-all ${
|
|
||||||
mode === 'signup'
|
|
||||||
? 'bg-white text-slate-900 shadow-sm'
|
|
||||||
: 'text-slate-500 hover:text-slate-900'
|
|
||||||
}`}
|
|
||||||
onClick={() => setMode('signup')}
|
|
||||||
>
|
|
||||||
Registreren
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Message Display */}
|
|
||||||
{message && (
|
|
||||||
<div
|
|
||||||
className={`mb-6 p-4 rounded-lg ${
|
|
||||||
message.type === 'success'
|
|
||||||
? 'bg-teal-50 text-teal-800 border border-teal-200'
|
|
||||||
: 'bg-red-50 text-red-800 border border-red-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<p className="text-sm font-medium">{message.text}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="email"
|
|
||||||
className="block text-sm font-medium text-slate-700 mb-1"
|
|
||||||
>
|
|
||||||
Email
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="email"
|
|
||||||
type="email"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
placeholder="jouw@email.nl"
|
|
||||||
required
|
|
||||||
className="w-full px-4 py-2.5 border border-slate-300 rounded-lg focus:ring-2 focus:ring-slate-400 focus:border-slate-400"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="password"
|
|
||||||
className="block text-sm font-medium text-slate-700 mb-1"
|
|
||||||
>
|
|
||||||
Wachtwoord
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
id="password"
|
|
||||||
type={showPassword ? 'text' : 'password'}
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
placeholder="••••••••"
|
|
||||||
required
|
|
||||||
minLength={8}
|
|
||||||
className="w-full px-4 py-2.5 pr-11 border border-slate-300 rounded-lg focus:ring-2 focus:ring-slate-400 focus:border-slate-400"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 transition-colors"
|
|
||||||
aria-label={showPassword ? 'Verberg wachtwoord' : 'Toon wachtwoord'}
|
|
||||||
>
|
|
||||||
{showPassword ? (
|
|
||||||
<EyeOff className="w-5 h-5" />
|
|
||||||
) : (
|
|
||||||
<Eye className="w-5 h-5" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{mode === 'login' && (
|
|
||||||
<div className="mt-1 text-right">
|
|
||||||
<Link
|
|
||||||
href="/reset-password"
|
|
||||||
className="text-xs text-slate-500 hover:text-teal-600"
|
|
||||||
>
|
|
||||||
Wachtwoord vergeten?
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{mode === 'signup' && (
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="confirmPassword"
|
|
||||||
className="block text-sm font-medium text-slate-700 mb-1"
|
|
||||||
>
|
|
||||||
Bevestig Wachtwoord
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
id="confirmPassword"
|
|
||||||
type={showConfirmPassword ? 'text' : 'password'}
|
|
||||||
value={confirmPassword}
|
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
||||||
placeholder="••••••••"
|
|
||||||
required
|
|
||||||
minLength={8}
|
|
||||||
className="w-full px-4 py-2.5 pr-11 border border-slate-300 rounded-lg focus:ring-2 focus:ring-slate-400 focus:border-slate-400"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 transition-colors"
|
|
||||||
aria-label={showConfirmPassword ? 'Verberg wachtwoord' : 'Toon wachtwoord'}
|
|
||||||
>
|
|
||||||
{showConfirmPassword ? (
|
|
||||||
<EyeOff className="w-5 h-5" />
|
|
||||||
) : (
|
|
||||||
<Eye className="w-5 h-5" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading}
|
|
||||||
className="w-full bg-teal-600 hover:bg-teal-700 text-white font-medium py-2.5 px-4 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{loading
|
|
||||||
? 'Laden...'
|
|
||||||
: mode === 'login' ? 'Inloggen' : 'Registreren'
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{/* Divider */}
|
|
||||||
<div className="relative my-6">
|
|
||||||
<div className="absolute inset-0 flex items-center">
|
|
||||||
<div className="w-full border-t border-slate-300" />
|
|
||||||
</div>
|
|
||||||
<div className="relative flex justify-center text-sm">
|
|
||||||
<span className="px-2 bg-white text-slate-500">of</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quick Demo Button */}
|
|
||||||
<button
|
|
||||||
onClick={handleQuickDemoLogin}
|
|
||||||
disabled={loading}
|
|
||||||
className="w-full bg-amber-50 hover:bg-amber-100 text-amber-800 text-sm font-medium py-2 px-4 rounded-lg border border-amber-200 transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
<span>⚡</span>
|
|
||||||
Demo Account Proberen
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<p className="text-center text-xs text-slate-500 mt-8">
|
|
||||||
Build in Public door{' '}
|
|
||||||
<a
|
|
||||||
href="https://ikbenlit.nl"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-teal-600 hover:text-teal-700 font-medium"
|
|
||||||
>
|
|
||||||
AI Speedrun
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
460
components/speech-recorder-streaming.tsx
Normal file
460
components/speech-recorder-streaming.tsx
Normal file
@@ -0,0 +1,460 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { Loader2, Mic, Pause, Play, Square, Settings, Clock } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import {
|
||||||
|
useDeepgramStreaming,
|
||||||
|
type ConnectionStatus,
|
||||||
|
type TranscriptResult,
|
||||||
|
type TranscriptWord,
|
||||||
|
} from '@/hooks/use-deepgram-streaming'
|
||||||
|
import { ConfidencePreview } from '@/components/confidence-text'
|
||||||
|
import { logSpeechUsage, type SpeechTelemetryOptions } from '@/lib/telemetry/speech'
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Types
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface SpeechRecorderStreamingProps {
|
||||||
|
/** Callback wanneer er een final transcript is */
|
||||||
|
onTranscript: (transcript: string) => void
|
||||||
|
/** Callback voor interim transcripts (optioneel, voor live preview) */
|
||||||
|
onInterimTranscript?: (interim: string) => void
|
||||||
|
/** Callback voor woorden met confidence scores */
|
||||||
|
onWordsUpdate?: (words: TranscriptWord[]) => void
|
||||||
|
/** Disabled state */
|
||||||
|
disabled?: boolean
|
||||||
|
/** Extra CSS classes */
|
||||||
|
className?: string
|
||||||
|
/** Callback wanneer opname start (voor cursor positioning) */
|
||||||
|
onRecordingStart?: () => void
|
||||||
|
/** Callback wanneer opname stopt */
|
||||||
|
onRecordingStop?: () => void
|
||||||
|
/** Optionele context voor telemetrie */
|
||||||
|
telemetryContext?: SpeechTelemetryOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Status Display Component
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function StatusIndicator({ status }: { status: ConnectionStatus }) {
|
||||||
|
const config: Record<
|
||||||
|
ConnectionStatus,
|
||||||
|
{ icon: string; color: string; label: string }
|
||||||
|
> = {
|
||||||
|
disconnected: {
|
||||||
|
icon: '◯',
|
||||||
|
color: 'text-slate-400',
|
||||||
|
label: 'Niet verbonden',
|
||||||
|
},
|
||||||
|
connecting: {
|
||||||
|
icon: '◐',
|
||||||
|
color: 'text-amber-500',
|
||||||
|
label: 'Verbinden...',
|
||||||
|
},
|
||||||
|
connected: {
|
||||||
|
icon: '●',
|
||||||
|
color: 'text-emerald-500',
|
||||||
|
label: 'Verbonden & streaming',
|
||||||
|
},
|
||||||
|
reconnecting: {
|
||||||
|
icon: '⚠',
|
||||||
|
color: 'text-orange-500',
|
||||||
|
label: 'Herverbinden...',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
icon: '✕',
|
||||||
|
color: 'text-red-500',
|
||||||
|
label: 'Fout',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const { icon, color, label } = config[status]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={cn('text-xs flex items-center gap-1', color)}>
|
||||||
|
<span className="text-sm">{icon}</span>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Waveform Visualizer Component
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function WaveformVisualizer({
|
||||||
|
analyserNode,
|
||||||
|
isActive,
|
||||||
|
}: {
|
||||||
|
analyserNode: AnalyserNode | null
|
||||||
|
isActive: boolean
|
||||||
|
}) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
|
const animationRef = useRef<number | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!analyserNode || !canvasRef.current || !isActive) {
|
||||||
|
if (animationRef.current) {
|
||||||
|
cancelAnimationFrame(animationRef.current)
|
||||||
|
animationRef.current = null
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
const bufferLength = analyserNode.frequencyBinCount
|
||||||
|
const dataArray = new Uint8Array(bufferLength)
|
||||||
|
|
||||||
|
const draw = () => {
|
||||||
|
animationRef.current = requestAnimationFrame(draw)
|
||||||
|
|
||||||
|
analyserNode.getByteFrequencyData(dataArray)
|
||||||
|
|
||||||
|
// Clear canvas
|
||||||
|
ctx.fillStyle = '#f8fafc' // slate-50
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||||
|
|
||||||
|
const barCount = 32
|
||||||
|
const barWidth = (canvas.width - (barCount - 1) * 2) / barCount
|
||||||
|
const maxBarHeight = canvas.height - 8
|
||||||
|
|
||||||
|
for (let i = 0; i < barCount; i++) {
|
||||||
|
// Sample from frequency data
|
||||||
|
const dataIndex = Math.floor((i / barCount) * bufferLength)
|
||||||
|
const value = dataArray[dataIndex] / 255
|
||||||
|
|
||||||
|
const barHeight = Math.max(4, value * maxBarHeight)
|
||||||
|
const x = i * (barWidth + 2)
|
||||||
|
const y = (canvas.height - barHeight) / 2
|
||||||
|
|
||||||
|
// Gradient from slate to emerald based on value
|
||||||
|
const intensity = Math.floor(value * 255)
|
||||||
|
ctx.fillStyle =
|
||||||
|
value > 0.3
|
||||||
|
? `rgb(${16 + (1 - value) * 50}, ${185 - (1 - value) * 100}, ${129 - (1 - value) * 50})`
|
||||||
|
: '#64748b' // slate-500
|
||||||
|
|
||||||
|
// Rounded bars
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.roundRect(x, y, barWidth, barHeight, 2)
|
||||||
|
ctx.fill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
draw()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (animationRef.current) {
|
||||||
|
cancelAnimationFrame(animationRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [analyserNode, isActive])
|
||||||
|
|
||||||
|
if (!isActive) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
width={300}
|
||||||
|
height={40}
|
||||||
|
className="rounded bg-slate-50"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Main Component
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function SpeechRecorderStreaming({
|
||||||
|
onTranscript,
|
||||||
|
onInterimTranscript,
|
||||||
|
onWordsUpdate,
|
||||||
|
disabled = false,
|
||||||
|
className,
|
||||||
|
onRecordingStart,
|
||||||
|
onRecordingStop,
|
||||||
|
telemetryContext,
|
||||||
|
}: SpeechRecorderStreamingProps) {
|
||||||
|
const [interimText, setInterimText] = useState('')
|
||||||
|
const [allWords, setAllWords] = useState<TranscriptWord[]>([])
|
||||||
|
const [localError, setLocalError] = useState<string | null>(null)
|
||||||
|
const [isAutoPaused, setIsAutoPaused] = useState(false)
|
||||||
|
|
||||||
|
// Accumulate final transcript parts
|
||||||
|
const finalPartsRef = useRef<string[]>([])
|
||||||
|
const autoPauseTriggeredRef = useRef(false)
|
||||||
|
const telemetryRef = useRef<SpeechTelemetryOptions | undefined>(telemetryContext)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
telemetryRef.current = telemetryContext
|
||||||
|
}, [telemetryContext])
|
||||||
|
|
||||||
|
const trackSpeechUsage = useCallback(
|
||||||
|
(action: 'start' | 'stop' | 'final', metadata?: Record<string, unknown>) => {
|
||||||
|
if (!telemetryRef.current) return
|
||||||
|
logSpeechUsage({ ...telemetryRef.current, action, metadata })
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleTranscript = useCallback(
|
||||||
|
(result: TranscriptResult) => {
|
||||||
|
if (result.isFinal) {
|
||||||
|
// Final transcript - accumulate en stuur naar parent
|
||||||
|
finalPartsRef.current.push(result.transcript)
|
||||||
|
setInterimText('')
|
||||||
|
|
||||||
|
// Update words met confidence
|
||||||
|
setAllWords((prev) => {
|
||||||
|
const next = [...prev, ...result.words]
|
||||||
|
onWordsUpdate?.(next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
// Stuur volledige tekst naar parent
|
||||||
|
onTranscript(finalPartsRef.current.join(' '))
|
||||||
|
trackSpeechUsage('final', {
|
||||||
|
chunkLength: result.transcript.length,
|
||||||
|
totalLength: finalPartsRef.current.join(' ').length,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check voor auto-pause na speech_final (3 sec stilte gedetecteerd door Deepgram)
|
||||||
|
if (result.speechFinal && !autoPauseTriggeredRef.current) {
|
||||||
|
autoPauseTriggeredRef.current = true
|
||||||
|
setIsAutoPaused(true)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Interim transcript - alleen preview
|
||||||
|
setInterimText(result.transcript)
|
||||||
|
onInterimTranscript?.(result.transcript)
|
||||||
|
|
||||||
|
// Reset auto-pause state bij nieuwe interim speech
|
||||||
|
if (isAutoPaused) {
|
||||||
|
setIsAutoPaused(false)
|
||||||
|
autoPauseTriggeredRef.current = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onTranscript, onInterimTranscript, onWordsUpdate, isAutoPaused, trackSpeechUsage]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleError = useCallback((error: Error) => {
|
||||||
|
setLocalError(error.message)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const {
|
||||||
|
status,
|
||||||
|
isRecording,
|
||||||
|
startRecording,
|
||||||
|
stopRecording,
|
||||||
|
pauseRecording,
|
||||||
|
resumeRecording,
|
||||||
|
isPaused,
|
||||||
|
error: hookError,
|
||||||
|
analyserNode,
|
||||||
|
isBrowserSupported,
|
||||||
|
} = useDeepgramStreaming({
|
||||||
|
onTranscript: handleTranscript,
|
||||||
|
onError: handleError,
|
||||||
|
})
|
||||||
|
|
||||||
|
const error = localError || hookError
|
||||||
|
|
||||||
|
const handleStart = async () => {
|
||||||
|
setLocalError(null)
|
||||||
|
finalPartsRef.current = []
|
||||||
|
setAllWords([])
|
||||||
|
setInterimText('')
|
||||||
|
setIsAutoPaused(false)
|
||||||
|
autoPauseTriggeredRef.current = false
|
||||||
|
onRecordingStart?.()
|
||||||
|
await startRecording()
|
||||||
|
trackSpeechUsage('start')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleStop = () => {
|
||||||
|
stopRecording()
|
||||||
|
setIsAutoPaused(false)
|
||||||
|
autoPauseTriggeredRef.current = false
|
||||||
|
onRecordingStop?.()
|
||||||
|
trackSpeechUsage('stop', {
|
||||||
|
totalLength: finalPartsRef.current.join(' ').length,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePauseResume = () => {
|
||||||
|
if (isPaused || isAutoPaused) {
|
||||||
|
setIsAutoPaused(false)
|
||||||
|
autoPauseTriggeredRef.current = false
|
||||||
|
resumeRecording()
|
||||||
|
} else {
|
||||||
|
pauseRecording()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isConnecting = status === 'connecting'
|
||||||
|
const isReconnecting = status === 'reconnecting'
|
||||||
|
const showWaveform = isRecording && !isPaused && !isAutoPaused && status === 'connected'
|
||||||
|
const effectivelyPaused = isPaused || isAutoPaused
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAutoPaused && isRecording && !isPaused) {
|
||||||
|
pauseRecording()
|
||||||
|
}
|
||||||
|
}, [isAutoPaused, isRecording, isPaused, pauseRecording])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg border border-slate-200 bg-white p-4 space-y-3',
|
||||||
|
isRecording && status === 'connected' && 'border-emerald-500 border-2 shadow-emerald-500/20 shadow-sm',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Mic className="h-4 w-4 text-slate-600" />
|
||||||
|
<span className="text-sm font-medium text-slate-900">Opname</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<StatusIndicator status={status} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="p-1 text-slate-400 hover:text-slate-600 rounded"
|
||||||
|
aria-label="Instellingen"
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Browser not supported message */}
|
||||||
|
{!isBrowserSupported && (
|
||||||
|
<div className="text-sm text-amber-700 bg-amber-50 p-2 rounded border border-amber-200">
|
||||||
|
⚠ Spraakopname wordt niet ondersteund in deze browser. Gebruik Chrome, Firefox of Edge voor de beste ervaring.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error message */}
|
||||||
|
{error && (
|
||||||
|
<div className="text-sm text-red-600 bg-red-50 p-2 rounded border border-red-200">
|
||||||
|
⚠ {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reconnecting message */}
|
||||||
|
{isReconnecting && (
|
||||||
|
<div className="text-sm text-orange-600 bg-orange-50 p-2 rounded border border-orange-200 flex items-center gap-2">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Herverbinden... Transcript blijft behouden.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Interim text preview */}
|
||||||
|
{interimText && (
|
||||||
|
<div className="text-sm text-slate-500 italic bg-slate-50 p-2 rounded">
|
||||||
|
{interimText}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Waveform */}
|
||||||
|
<WaveformVisualizer analyserNode={analyserNode} isActive={showWaveform} />
|
||||||
|
|
||||||
|
{/* Confidence preview */}
|
||||||
|
{allWords.length > 0 && (
|
||||||
|
<ConfidencePreview words={allWords} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Auto-pause message (3 sec stilte) */}
|
||||||
|
{isAutoPaused && isRecording && (
|
||||||
|
<div className="text-sm text-amber-700 bg-amber-50 p-2 rounded border border-amber-200 flex items-center gap-2">
|
||||||
|
<Clock className="h-4 w-4" />
|
||||||
|
<span>
|
||||||
|
<strong>Automatisch gepauzeerd</strong>
|
||||||
|
<span className="text-amber-600 ml-1">(3 seconden stilte)</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Manual pause message */}
|
||||||
|
{isPaused && !isAutoPaused && isRecording && (
|
||||||
|
<div className="text-sm text-slate-600 bg-slate-100 p-2 rounded flex items-center gap-2">
|
||||||
|
<Pause className="h-4 w-4" />
|
||||||
|
Gepauzeerd
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{!isRecording ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleStart}
|
||||||
|
disabled={disabled || isConnecting || !isBrowserSupported}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors',
|
||||||
|
'bg-emerald-600 text-white hover:bg-emerald-700',
|
||||||
|
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isConnecting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Verbinden...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Mic className="h-4 w-4" />
|
||||||
|
Start opname
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handlePauseResume}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors',
|
||||||
|
effectivelyPaused
|
||||||
|
? 'bg-emerald-100 border border-emerald-300 text-emerald-700 hover:bg-emerald-200'
|
||||||
|
: 'border border-slate-300 text-slate-700 hover:bg-slate-50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{effectivelyPaused ? (
|
||||||
|
<>
|
||||||
|
<Play className="h-4 w-4" />
|
||||||
|
Hervat
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Pause className="h-4 w-4" />
|
||||||
|
Pauzeer
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleStop}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors',
|
||||||
|
'bg-red-600 text-white hover:bg-red-700'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Square className="h-4 w-4" />
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
import { ArrowRight } from "lucide-react";
|
import { ArrowRight } from "lucide-react";
|
||||||
|
|
||||||
|
|||||||
259
docs/reports/efficiency-plan.md
Normal file
259
docs/reports/efficiency-plan.md
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
# Efficiëntieplan — Patiëntenworkspace & Spraakstreaming
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
Dit plan prioriteert optimalisaties op basis van **bundelimpact** en **implementatie-effort**.
|
||||||
|
De grootste winst zit in lazy loading van zware editors, niet in de Deepgram SDK.
|
||||||
|
|
||||||
|
### Quick Reference
|
||||||
|
|
||||||
|
| Prioriteit | Optimalisatie | Impact | Effort |
|
||||||
|
|------------|---------------|--------|--------|
|
||||||
|
| P0 | TipTap lazy-load | ~94 kB | Laag |
|
||||||
|
| P1 | Login page refactor | ~76 kB | Laag |
|
||||||
|
| P2 | Speech recorder lazy-load | ~25-30 kB | Laag |
|
||||||
|
| P3 | Modal lazy-load | ~15-20 kB | Laag |
|
||||||
|
| P4 | Server components timeline | ~10-15 kB | Hoog |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bundelanalyse (25-11-2025)
|
||||||
|
|
||||||
|
### Route Ranking (hoogste First Load JS)
|
||||||
|
|
||||||
|
| Route | Page Size | First Load JS | Bottleneck |
|
||||||
|
|-------|-----------|---------------|------------|
|
||||||
|
| `/behandeladvies` | **154 kB** | **240 kB** | TipTap + Deepgram |
|
||||||
|
| `/login` | 76 kB | 162 kB | BentoGrid (hele page client) |
|
||||||
|
| `/reset-password` | 63.7 kB | 150 kB | Auth forms |
|
||||||
|
| `/rapportage` | 60 kB | 146 kB | Deepgram + resizable-panels |
|
||||||
|
| `/` (homepage) | 60.7 kB | 147 kB | Marketing UI |
|
||||||
|
| `/intakes/new` | 43.7 kB | 130 kB | react-hook-form + zod |
|
||||||
|
| `/screening` | 20.1 kB | 106 kB | Decision cards |
|
||||||
|
|
||||||
|
### Build Snapshot (26-11-2025)
|
||||||
|
|
||||||
|
| Route | Page Size | First Load JS | Opmerking |
|
||||||
|
|-------|-----------|---------------|-----------|
|
||||||
|
| `/epd/patients/[id]/rapportage` | **25.7 kB** | **112 kB** | -34 kB vs baseline dankzij lazy modals & panels |
|
||||||
|
| `/epd/patients/[id]/intakes/new` | 43.8 kB | 130 kB | Form island, maar shared chunk blijft gelijk |
|
||||||
|
| `/login` | 65.7 kB | 152 kB | Hero nog client heavy – gepland in latere fase |
|
||||||
|
|
||||||
|
> Grootste winst zichtbaar op rapportage route; andere pagina's vereisen aanvullende workstreams (marketing assets, shared chunks) om verder te dalen.
|
||||||
|
|
||||||
|
### Package Sizes (raw disk)
|
||||||
|
|
||||||
|
| Package | Size | Lazy-loadbaar |
|
||||||
|
|---------|------|---------------|
|
||||||
|
| `@tiptap/*` | ~5+ MB | ✓ |
|
||||||
|
| `@deepgram/sdk` | ~2.5 MB | ✓ |
|
||||||
|
| `react-resizable-panels` | ~650 KB | ✓ |
|
||||||
|
| `react-hook-form` + `zod` | ~1.5 MB | ✓ |
|
||||||
|
|
||||||
|
### Aanname getoetst: "Deepgram SDK veroorzaakt performance issues"
|
||||||
|
|
||||||
|
**Conclusie: Aanname is NIET correct.**
|
||||||
|
|
||||||
|
- Rapportage (60 kB) met Deepgram is **94 kB kleiner** dan behandeladvies (154 kB)
|
||||||
|
- Beide gebruiken `SpeechRecorderStreaming` met Deepgram
|
||||||
|
- Het verschil komt door **TipTap RichTextEditor** (alleen in behandeladvies)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fase-indeling (Geprioriteerd op ROI)
|
||||||
|
|
||||||
|
### Fase 0 — Quick Wins (Hoogste ROI, laagste effort)
|
||||||
|
|
||||||
|
#### 0.1 TipTap lazy-load in behandeladvies (~94 kB besparing)
|
||||||
|
```tsx
|
||||||
|
// Huidige situatie (slecht)
|
||||||
|
import { RichTextEditor } from '@/components/rich-text-editor';
|
||||||
|
|
||||||
|
// Nieuwe situatie (goed)
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
const RichTextEditor = dynamic(
|
||||||
|
() => import('@/components/rich-text-editor').then(m => m.RichTextEditor),
|
||||||
|
{ ssr: false, loading: () => <EditorSkeleton /> }
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
_Status 26-11-2025: ✅ Ingezet in `treatment-advice-form` inclusief skeleton; bundel wacht nu tot interactie._
|
||||||
|
|
||||||
|
**Criteria voor succes:** `/behandeladvies` page size < 70 kB
|
||||||
|
|
||||||
|
#### 0.2 Login page refactor (~76 kB besparing)
|
||||||
|
- Login page is volledig `'use client'` terwijl 60%+ statische marketing content is
|
||||||
|
- Refactor naar server component met client islands voor form en BentoGrid interacties
|
||||||
|
|
||||||
|
**Criteria voor succes:** `/login` page size < 30 kB
|
||||||
|
|
||||||
|
_Status 26-11-2025: ✅ Page is nu server-rendered; enkel het formulier is een client-island._
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Fase 1 — Speech & Modal Optimalisatie
|
||||||
|
|
||||||
|
#### 1.1 SpeechRecorderStreaming lazy-load (~25-30 kB per page)
|
||||||
|
```tsx
|
||||||
|
const SpeechRecorderStreaming = dynamic(
|
||||||
|
() => import('@/components/speech-recorder-streaming').then(m => m.SpeechRecorderStreaming),
|
||||||
|
{ ssr: false, loading: () => <RecorderSkeleton /> }
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Toepassingslocaties:**
|
||||||
|
- `treatment-advice-form.tsx` (behandeladvies)
|
||||||
|
- `report-composer.tsx` (rapportage)
|
||||||
|
|
||||||
|
_Status 26-11-2025: ✅ Beide formulieren laden de recorder nu lazy met een kleine skeleton._
|
||||||
|
|
||||||
|
#### 1.2 ReportViewEditModal lazy-load (~15-20 kB)
|
||||||
|
Modal wordt alleen getoond bij klikken op een rapport:
|
||||||
|
```tsx
|
||||||
|
const ReportViewEditModal = dynamic(
|
||||||
|
() => import('./report-view-edit-modal').then(m => m.ReportViewEditModal),
|
||||||
|
{ ssr: false }
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
_Status 26-11-2025: ✅ Modal en Deepgram chunk worden alleen geladen wanneer een kaart wordt geopend._
|
||||||
|
|
||||||
|
#### 1.3 Telemetrie toevoegen
|
||||||
|
Log spraakgebruik om te meten hoeveel gebruikers de Deepgram chunk daadwerkelijk nodig hebben.
|
||||||
|
|
||||||
|
_Status 26-11-2025: ✅ Nieuwe `speech_usage_events` tabel + API route; recorder logt start/stop/final events met context._
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Fase 2 — Form Optimalisatie
|
||||||
|
|
||||||
|
#### 2.1 react-hook-form + zod lazy-load (~30-40 kB)
|
||||||
|
NewIntakeForm laadt zware form libraries direct. Lazy-load de hele form:
|
||||||
|
```tsx
|
||||||
|
const NewIntakeForm = dynamic(
|
||||||
|
() => import('../components/new-intake-form').then(m => m.NewIntakeForm),
|
||||||
|
{ ssr: false, loading: () => <FormSkeleton /> }
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
_Status 26-11-2025: ✅ Pagina `intakes/new` laadt de form nu als island met skeleton._
|
||||||
|
|
||||||
|
#### 2.2 react-resizable-panels lazy-load
|
||||||
|
RapportageWorkspaceV2 laadt dit direct. Overweeg een simpelere layout als default.
|
||||||
|
|
||||||
|
_Status 26-11-2025: ✅ Panel library wordt client-side geladen met stacked fallback; timeline/composer blijven bruikbaar terwijl chunk downloadt._
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Fase 3 — Server Components & Data Fetching
|
||||||
|
|
||||||
|
> **Let op:** Deze fase heeft hoge effort maar medium winst. Alleen implementeren na Fase 0-2.
|
||||||
|
|
||||||
|
#### 3.1 Timeline naar server component
|
||||||
|
Huidige situatie: `ReportTimeline` is volledig client-side voor filtering/zoeken.
|
||||||
|
- Verplaats statische rendering (kaarten, timestamps) naar server
|
||||||
|
- Behoud alleen filter-controls als client island
|
||||||
|
|
||||||
|
_Status 26-11-2025: 🔄 Nog te doen. Vereist opsplitsing van RapportageWorkspaceV2 + nieuwe client island._
|
||||||
|
|
||||||
|
#### 3.2 Header naar server component
|
||||||
|
Patient info en breadcrumbs kunnen server-side renderen.
|
||||||
|
|
||||||
|
_Status 26-11-2025: 🔄 Nog te doen. Wordt opgepakt na timeline refactor._
|
||||||
|
|
||||||
|
#### 3.3 Server actions & caching
|
||||||
|
- Patient + rapportages via server actions laden met `cache()`
|
||||||
|
- Gerichte `revalidateTag` bij mutaties
|
||||||
|
|
||||||
|
_Status 26-11-2025: 🔄 Gepland na 3.1/3.2 om dataflow te vereenvoudigen._
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Fase 4 — Build & Tooling (Maintenance)
|
||||||
|
|
||||||
|
#### 4.1 Webpack cache waarschuwing oplossen
|
||||||
|
```
|
||||||
|
[webpack.cache.PackFileCacheStrategy] Serializing big strings (128kiB)
|
||||||
|
```
|
||||||
|
Grote stringassets omzetten naar Buffers of opsplitsen.
|
||||||
|
|
||||||
|
_Status 26-11-2025: ✅ Productiebouw gebruikt nu een in-memory webpack cache, waardoor de PackFileCacheStrategy waarschuwing verdwijnt._
|
||||||
|
|
||||||
|
#### 4.2 Performance budget in CI
|
||||||
|
```bash
|
||||||
|
# CI check
|
||||||
|
pnpm build && pnpm check:bundle
|
||||||
|
```
|
||||||
|
|
||||||
|
Budgetten:
|
||||||
|
- Max 150 kB First Load JS per EPD route
|
||||||
|
- Max 250 kB First Load JS voor behandeladvies (met editor)
|
||||||
|
|
||||||
|
_Status 26-11-2025: ✅ `scripts/check-bundle-size.js` scant de route-specifieke chunks (exclusief shared webpack/main) na `pnpm build`; bundels falen wanneer `/rapportage` of behandeladvies boven hun budget komt (`pnpm check:bundle`)._
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementatie Roadmap
|
||||||
|
|
||||||
|
```
|
||||||
|
Week 1: Fase 0 (Quick Wins)
|
||||||
|
├── 0.1 TipTap lazy-load
|
||||||
|
└── 0.2 Login page refactor
|
||||||
|
|
||||||
|
Week 2: Fase 1 (Speech & Modals)
|
||||||
|
├── 1.1 Speech recorder lazy-load
|
||||||
|
├── 1.2 Modal lazy-load
|
||||||
|
└── 1.3 Telemetrie setup
|
||||||
|
|
||||||
|
Week 3: Fase 2 (Forms) + Meting
|
||||||
|
├── 2.1 Form lazy-load
|
||||||
|
├── 2.2 Resizable panels review
|
||||||
|
└── Bundle size meting vs baseline
|
||||||
|
|
||||||
|
Week 4+: Fase 3-4 (indien nodig)
|
||||||
|
├── Server components (hoog effort)
|
||||||
|
└── CI tooling
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Metrics & Doelen
|
||||||
|
|
||||||
|
### Baseline (25-11-2025)
|
||||||
|
|
||||||
|
| Metric | Huidige waarde |
|
||||||
|
|--------|----------------|
|
||||||
|
| `/behandeladvies` First Load | 240 kB |
|
||||||
|
| `/rapportage` First Load | 146 kB |
|
||||||
|
| `/login` First Load | 162 kB |
|
||||||
|
| Dev compile modules | ~3000 |
|
||||||
|
|
||||||
|
### Target na Fase 0-2
|
||||||
|
|
||||||
|
| Metric | Doel |
|
||||||
|
|--------|------|
|
||||||
|
| `/behandeladvies` First Load | < 150 kB (-38%) |
|
||||||
|
| `/rapportage` First Load | < 120 kB (-18%) |
|
||||||
|
| `/login` First Load | < 100 kB (-38%) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architectuur Observaties
|
||||||
|
|
||||||
|
### Positief
|
||||||
|
- Pages zijn al server components
|
||||||
|
- Data fetching gebeurt server-side met `async` page components
|
||||||
|
- Supabase auth via server actions
|
||||||
|
|
||||||
|
### Te verbeteren
|
||||||
|
- Client components bevatten ALLE UI + logica (geen code splitting)
|
||||||
|
- Modals en editors laden direct in initial bundle
|
||||||
|
- Login page is volledig client terwijl content grotendeels statisch is
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Referenties
|
||||||
|
|
||||||
|
- Build output: `docs/reports/20251125_build output.md`
|
||||||
|
- Next.js Dynamic Imports: https://nextjs.org/docs/app/building-your-application/optimizing/lazy-loading
|
||||||
|
- Bundle Analyzer: `next build && ANALYZE=true next build`
|
||||||
@@ -12,31 +12,6 @@ export type Database = {
|
|||||||
__InternalSupabase: {
|
__InternalSupabase: {
|
||||||
PostgrestVersion: "13.0.5"
|
PostgrestVersion: "13.0.5"
|
||||||
}
|
}
|
||||||
graphql_public: {
|
|
||||||
Tables: {
|
|
||||||
[_ in never]: never
|
|
||||||
}
|
|
||||||
Views: {
|
|
||||||
[_ in never]: never
|
|
||||||
}
|
|
||||||
Functions: {
|
|
||||||
graphql: {
|
|
||||||
Args: {
|
|
||||||
extensions?: Json
|
|
||||||
operationName?: string
|
|
||||||
query?: string
|
|
||||||
variables?: Json
|
|
||||||
}
|
|
||||||
Returns: Json
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Enums: {
|
|
||||||
[_ in never]: never
|
|
||||||
}
|
|
||||||
CompositeTypes: {
|
|
||||||
[_ in never]: never
|
|
||||||
}
|
|
||||||
}
|
|
||||||
public: {
|
public: {
|
||||||
Tables: {
|
Tables: {
|
||||||
ai_events: {
|
ai_events: {
|
||||||
@@ -1103,6 +1078,78 @@ export type Database = {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
reports: {
|
||||||
|
Row: {
|
||||||
|
ai_confidence: number | null
|
||||||
|
ai_reasoning: string | null
|
||||||
|
audio_duration_seconds: number | null
|
||||||
|
audio_url: string | null
|
||||||
|
content: string
|
||||||
|
created_at: string
|
||||||
|
created_by: string | null
|
||||||
|
deleted_at: string | null
|
||||||
|
id: string
|
||||||
|
parent_report_id: string | null
|
||||||
|
patient_id: string
|
||||||
|
structured_data: Json | null
|
||||||
|
type: string
|
||||||
|
updated_at: string | null
|
||||||
|
updated_by: string | null
|
||||||
|
version: string | null
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
ai_confidence?: number | null
|
||||||
|
ai_reasoning?: string | null
|
||||||
|
audio_duration_seconds?: number | null
|
||||||
|
audio_url?: string | null
|
||||||
|
content: string
|
||||||
|
created_at?: string
|
||||||
|
created_by?: string | null
|
||||||
|
deleted_at?: string | null
|
||||||
|
id?: string
|
||||||
|
parent_report_id?: string | null
|
||||||
|
patient_id: string
|
||||||
|
structured_data?: Json | null
|
||||||
|
type: string
|
||||||
|
updated_at?: string | null
|
||||||
|
updated_by?: string | null
|
||||||
|
version?: string | null
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
ai_confidence?: number | null
|
||||||
|
ai_reasoning?: string | null
|
||||||
|
audio_duration_seconds?: number | null
|
||||||
|
audio_url?: string | null
|
||||||
|
content?: string
|
||||||
|
created_at?: string
|
||||||
|
created_by?: string | null
|
||||||
|
deleted_at?: string | null
|
||||||
|
id?: string
|
||||||
|
parent_report_id?: string | null
|
||||||
|
patient_id?: string
|
||||||
|
structured_data?: Json | null
|
||||||
|
type?: string
|
||||||
|
updated_at?: string | null
|
||||||
|
updated_by?: string | null
|
||||||
|
version?: string | null
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "reports_parent_report_id_fkey"
|
||||||
|
columns: ["parent_report_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "reports"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "reports_patient_id_fkey"
|
||||||
|
columns: ["patient_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "patients"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
risk_assessments: {
|
risk_assessments: {
|
||||||
Row: {
|
Row: {
|
||||||
assessment_date: string
|
assessment_date: string
|
||||||
@@ -1320,6 +1367,42 @@ export type Database = {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
speech_usage_events: {
|
||||||
|
Row: {
|
||||||
|
action: string
|
||||||
|
context: string
|
||||||
|
created_at: string
|
||||||
|
id: string
|
||||||
|
intake_id: string | null
|
||||||
|
metadata: Json
|
||||||
|
patient_id: string | null
|
||||||
|
report_id: string | null
|
||||||
|
user_id: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
action: string
|
||||||
|
context: string
|
||||||
|
created_at?: string
|
||||||
|
id?: string
|
||||||
|
intake_id?: string | null
|
||||||
|
metadata?: Json
|
||||||
|
patient_id?: string | null
|
||||||
|
report_id?: string | null
|
||||||
|
user_id: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
action?: string
|
||||||
|
context?: string
|
||||||
|
created_at?: string
|
||||||
|
id?: string
|
||||||
|
intake_id?: string | null
|
||||||
|
metadata?: Json
|
||||||
|
patient_id?: string | null
|
||||||
|
report_id?: string | null
|
||||||
|
user_id?: string
|
||||||
|
}
|
||||||
|
Relationships: []
|
||||||
|
}
|
||||||
treatment_plans: {
|
treatment_plans: {
|
||||||
Row: {
|
Row: {
|
||||||
client_id: string
|
client_id: string
|
||||||
@@ -1364,92 +1447,6 @@ export type Database = {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
reports: {
|
|
||||||
Row: {
|
|
||||||
ai_confidence: number | null
|
|
||||||
ai_reasoning: string | null
|
|
||||||
audio_duration_seconds: number | null
|
|
||||||
audio_url: string | null
|
|
||||||
content: string
|
|
||||||
created_at: string
|
|
||||||
created_by: string | null
|
|
||||||
deleted_at: string | null
|
|
||||||
id: string
|
|
||||||
parent_report_id: string | null
|
|
||||||
patient_id: string
|
|
||||||
structured_data: Json
|
|
||||||
type: string
|
|
||||||
updated_at: string | null
|
|
||||||
updated_by: string | null
|
|
||||||
version: string | null
|
|
||||||
}
|
|
||||||
Insert: {
|
|
||||||
ai_confidence?: number | null
|
|
||||||
ai_reasoning?: string | null
|
|
||||||
audio_duration_seconds?: number | null
|
|
||||||
audio_url?: string | null
|
|
||||||
content: string
|
|
||||||
created_at?: string
|
|
||||||
created_by?: string | null
|
|
||||||
deleted_at?: string | null
|
|
||||||
id?: string
|
|
||||||
parent_report_id?: string | null
|
|
||||||
patient_id: string
|
|
||||||
structured_data?: Json
|
|
||||||
type: string
|
|
||||||
updated_at?: string | null
|
|
||||||
updated_by?: string | null
|
|
||||||
version?: string | null
|
|
||||||
}
|
|
||||||
Update: {
|
|
||||||
ai_confidence?: number | null
|
|
||||||
ai_reasoning?: string | null
|
|
||||||
audio_duration_seconds?: number | null
|
|
||||||
audio_url?: string | null
|
|
||||||
content?: string
|
|
||||||
created_at?: string
|
|
||||||
created_by?: string | null
|
|
||||||
deleted_at?: string | null
|
|
||||||
id?: string
|
|
||||||
parent_report_id?: string | null
|
|
||||||
patient_id?: string
|
|
||||||
structured_data?: Json
|
|
||||||
type?: string
|
|
||||||
updated_at?: string | null
|
|
||||||
updated_by?: string | null
|
|
||||||
version?: string | null
|
|
||||||
}
|
|
||||||
Relationships: [
|
|
||||||
{
|
|
||||||
foreignKeyName: "reports_created_by_fkey"
|
|
||||||
columns: ["created_by"]
|
|
||||||
isOneToOne: false
|
|
||||||
referencedRelation: "practitioners"
|
|
||||||
referencedColumns: ["id"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
foreignKeyName: "reports_parent_report_id_fkey"
|
|
||||||
columns: ["parent_report_id"]
|
|
||||||
isOneToOne: false
|
|
||||||
referencedRelation: "reports"
|
|
||||||
referencedColumns: ["id"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
foreignKeyName: "reports_patient_id_fkey"
|
|
||||||
columns: ["patient_id"]
|
|
||||||
isOneToOne: false
|
|
||||||
referencedRelation: "patients"
|
|
||||||
referencedColumns: ["id"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
foreignKeyName: "reports_updated_by_fkey"
|
|
||||||
columns: ["updated_by"]
|
|
||||||
isOneToOne: false
|
|
||||||
referencedRelation: "practitioners"
|
|
||||||
referencedColumns: ["id"]
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Views: {
|
Views: {
|
||||||
active_intakes_overview: {
|
active_intakes_overview: {
|
||||||
@@ -1646,9 +1643,6 @@ export type CompositeTypes<
|
|||||||
: never
|
: never
|
||||||
|
|
||||||
export const Constants = {
|
export const Constants = {
|
||||||
graphql_public: {
|
|
||||||
Enums: {},
|
|
||||||
},
|
|
||||||
public: {
|
public: {
|
||||||
Enums: {
|
Enums: {
|
||||||
careplan_status: [
|
careplan_status: [
|
||||||
|
|||||||
42
lib/telemetry/speech.ts
Normal file
42
lib/telemetry/speech.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
interface SpeechTelemetryContext {
|
||||||
|
context: string
|
||||||
|
patientId?: string
|
||||||
|
intakeId?: string
|
||||||
|
reportId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SpeechTelemetryAction = 'start' | 'stop' | 'final'
|
||||||
|
|
||||||
|
export type SpeechTelemetryPayload = SpeechTelemetryContext & {
|
||||||
|
action: SpeechTelemetryAction
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logSpeechUsage(payload: SpeechTelemetryPayload) {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
const body = JSON.stringify(payload)
|
||||||
|
|
||||||
|
if (navigator.sendBeacon) {
|
||||||
|
try {
|
||||||
|
const blob = new Blob([body], { type: 'application/json' })
|
||||||
|
navigator.sendBeacon('/api/telemetry/speech', blob)
|
||||||
|
return
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('sendBeacon speech telemetry failed, falling back to fetch', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch('/api/telemetry/speech', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
credentials: 'same-origin',
|
||||||
|
keepalive: true,
|
||||||
|
}).catch((error) => {
|
||||||
|
console.warn('Speech telemetry fetch failed', error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SpeechTelemetryOptions = SpeechTelemetryContext
|
||||||
@@ -43,6 +43,11 @@ const nextConfig = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Avoid serializing very large strings into webpack's filesystem cache during production builds
|
||||||
|
if (!dev) {
|
||||||
|
config.cache = { type: 'memory' };
|
||||||
|
}
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,12 +5,14 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
|
"check:bundle": "node scripts/check-bundle-size.js",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"types:generate": "npx supabase gen types typescript --project-id dqugbrpwtisgyxscpefg > lib/supabase/types.ts",
|
"types:generate": "npx supabase gen types typescript --project-id dqugbrpwtisgyxscpefg > lib/supabase/types.ts",
|
||||||
"setup:auth-hook": "tsx scripts/setup-auth-hook.ts"
|
"setup:auth-hook": "tsx scripts/setup-auth-hook.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@deepgram/sdk": "^4.11.2",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
@@ -36,6 +38,7 @@
|
|||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-hook-form": "^7.66.1",
|
"react-hook-form": "^7.66.1",
|
||||||
|
"react-resizable-panels": "^3.0.6",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"three": "^0.181.1",
|
"three": "^0.181.1",
|
||||||
|
|||||||
85
scripts/check-bundle-size.js
Normal file
85
scripts/check-bundle-size.js
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { existsSync, readFileSync, statSync } from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
const manifestPath = path.join('.next', 'app-build-manifest.json')
|
||||||
|
|
||||||
|
if (!existsSync(manifestPath)) {
|
||||||
|
console.error('✖ Bundle manifest ontbreekt. Voer eerst `next build` uit.')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
|
||||||
|
|
||||||
|
const budgets = [
|
||||||
|
{
|
||||||
|
label: '/epd/patients/[id]/rapportage',
|
||||||
|
manifestKey: '/epd/patients/[id]/rapportage/page',
|
||||||
|
maxKB: 150,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '/epd/patients/[id]/intakes/[intakeId]/behandeladvies',
|
||||||
|
manifestKey: '/epd/patients/[id]/intakes/[intakeId]/behandeladvies/page',
|
||||||
|
maxKB: 250,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function formatKB(bytes) {
|
||||||
|
return Math.round((bytes / 1024) * 10) / 10
|
||||||
|
}
|
||||||
|
|
||||||
|
const SHARED_CHUNK_PATTERNS = [/^static\/chunks\/webpack/, /^static\/chunks\/main-app/]
|
||||||
|
|
||||||
|
function isSharedChunk(chunkName) {
|
||||||
|
return SHARED_CHUNK_PATTERNS.some((pattern) => pattern.test(chunkName))
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateRouteSize(manifestKey) {
|
||||||
|
const chunks = manifest.pages?.[manifestKey]
|
||||||
|
if (!chunks) {
|
||||||
|
throw new Error(`Geen entry voor ${manifestKey} in app-build-manifest.json`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const seen = new Set()
|
||||||
|
let totalBytes = 0
|
||||||
|
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
if (seen.has(chunk)) continue
|
||||||
|
seen.add(chunk)
|
||||||
|
if (isSharedChunk(chunk)) continue
|
||||||
|
const filePath = path.join('.next', chunk)
|
||||||
|
if (!existsSync(filePath)) continue
|
||||||
|
totalBytes += statSync(filePath).size
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
const violations = []
|
||||||
|
|
||||||
|
for (const budget of budgets) {
|
||||||
|
try {
|
||||||
|
const sizeBytes = calculateRouteSize(budget.manifestKey)
|
||||||
|
const sizeKB = formatKB(sizeBytes)
|
||||||
|
const maxKB = budget.maxKB
|
||||||
|
if (sizeKB > maxKB) {
|
||||||
|
violations.push(
|
||||||
|
`${budget.label}: ${sizeKB} kB > budget ${maxKB} kB (manifest: ${budget.manifestKey})`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
console.log(`✓ ${budget.label}: ${sizeKB} kB (budget ${maxKB} kB)`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
violations.push(error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (violations.length > 0) {
|
||||||
|
console.error('\nBundel-check mislukt:')
|
||||||
|
for (const message of violations) {
|
||||||
|
console.error(`- ${message}`)
|
||||||
|
}
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nAlle bundels binnen budget. ✅')
|
||||||
30
supabase/migrations/20251126_add_speech_usage_events.sql
Normal file
30
supabase/migrations/20251126_add_speech_usage_events.sql
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
-- ================================================
|
||||||
|
-- Speech Usage Telemetry Table
|
||||||
|
-- Created: 2025-11-26
|
||||||
|
-- Epic: Performance ROI - Speech telemetry
|
||||||
|
-- ================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS speech_usage_events (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
patient_id TEXT,
|
||||||
|
intake_id TEXT,
|
||||||
|
report_id TEXT,
|
||||||
|
action TEXT NOT NULL CHECK (action IN ('start', 'stop', 'final')),
|
||||||
|
context TEXT NOT NULL,
|
||||||
|
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_speech_usage_events_created ON speech_usage_events(created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_speech_usage_events_context ON speech_usage_events(context);
|
||||||
|
|
||||||
|
ALTER TABLE speech_usage_events ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY "Allow insert for authenticated users" ON speech_usage_events
|
||||||
|
FOR INSERT
|
||||||
|
WITH CHECK (auth.uid() = user_id);
|
||||||
|
|
||||||
|
CREATE POLICY "Allow select own telemetry" ON speech_usage_events
|
||||||
|
FOR SELECT
|
||||||
|
USING (auth.uid() = user_id);
|
||||||
Reference in New Issue
Block a user