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';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
import { useState, useTransition, useCallback } from 'react';
|
||||
import { saveTreatmentAdvice } from '../../actions';
|
||||
import { Loader2, Calendar, UserCircle, ClipboardList, Share2, CheckCircle2 } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { RichTextEditor } from '@/components/rich-text-editor';
|
||||
import { SpeechRecorder } from '@/components/speech-recorder';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
const RichTextEditor = dynamic(
|
||||
() => import('@/components/rich-text-editor').then((m) => m.RichTextEditor),
|
||||
{ ssr: false, loading: () => <EditorSkeleton /> }
|
||||
);
|
||||
|
||||
const SpeechRecorderStreaming = dynamic(
|
||||
() => import('@/components/speech-recorder-streaming').then((m) => m.SpeechRecorderStreaming),
|
||||
{ ssr: false, loading: () => <RecorderSkeleton /> }
|
||||
);
|
||||
|
||||
function EditorSkeleton() {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="h-8 w-3/4 rounded bg-slate-100 animate-pulse" />
|
||||
<div className="rounded-lg border border-slate-200 h-32 animate-pulse bg-slate-50" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecorderSkeleton() {
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-4 animate-pulse">
|
||||
<div className="h-4 w-1/2 rounded bg-slate-100 mb-2" />
|
||||
<div className="h-10 rounded bg-slate-100" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AdviceData {
|
||||
advice?: string;
|
||||
@@ -52,8 +79,10 @@ export function TreatmentAdviceForm({ patientId, intakeId, initialData, initialD
|
||||
const [finalize, setFinalize] = useState(Boolean(initialData.outcome));
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [interimText, setInterimText] = useState('');
|
||||
|
||||
const appendTranscript = (text: string) => {
|
||||
const appendTranscript = useCallback((text: string) => {
|
||||
if (!text) return;
|
||||
const sanitized = text
|
||||
.split('\n')
|
||||
@@ -67,7 +96,16 @@ export function TreatmentAdviceForm({ patientId, intakeId, initialData, initialD
|
||||
)
|
||||
.join('');
|
||||
setForm((prev) => ({ ...prev, advice: `${prev.advice || ''}${sanitized}` }));
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleRecordingStart = useCallback(() => {
|
||||
setIsStreaming(true);
|
||||
}, []);
|
||||
|
||||
const handleRecordingStop = useCallback(() => {
|
||||
setIsStreaming(false);
|
||||
setInterimText('');
|
||||
}, []);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!form.advice) {
|
||||
@@ -217,7 +255,22 @@ export function TreatmentAdviceForm({ patientId, intakeId, initialData, initialD
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<SpeechRecorder onTranscript={appendTranscript} />
|
||||
<SpeechRecorderStreaming
|
||||
onTranscript={appendTranscript}
|
||||
onInterimTranscript={setInterimText}
|
||||
onRecordingStart={handleRecordingStart}
|
||||
onRecordingStop={handleRecordingStop}
|
||||
telemetryContext={{
|
||||
context: 'treatment_advice',
|
||||
patientId,
|
||||
intakeId,
|
||||
}}
|
||||
/>
|
||||
{interimText && (
|
||||
<div className="text-sm text-slate-500 italic bg-slate-50 p-2 rounded border border-slate-200">
|
||||
{interimText}
|
||||
</div>
|
||||
)}
|
||||
<RichTextEditor
|
||||
value={form.advice}
|
||||
onChange={(html) => setForm((prev) => ({ ...prev, advice: html }))}
|
||||
|
||||
@@ -1,7 +1,26 @@
|
||||
import { NewIntakeForm } from '../components/new-intake-form';
|
||||
import dynamic from 'next/dynamic';
|
||||
import Link from 'next/link';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
|
||||
const NewIntakeForm = dynamic(
|
||||
() => import('../components/new-intake-form').then((m) => m.NewIntakeForm),
|
||||
{ ssr: false, loading: () => <FormSkeleton /> }
|
||||
);
|
||||
|
||||
function FormSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4 animate-pulse">
|
||||
<div className="h-5 w-1/3 rounded bg-slate-200" />
|
||||
<div className="h-10 rounded bg-slate-100" />
|
||||
<div className="h-5 w-1/4 rounded bg-slate-200" />
|
||||
<div className="h-10 rounded bg-slate-100" />
|
||||
<div className="h-5 w-1/4 rounded bg-slate-200" />
|
||||
<div className="h-10 rounded bg-slate-100" />
|
||||
<div className="h-10 rounded bg-slate-200" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface NewIntakePageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
@@ -31,4 +50,3 @@ export default async function NewIntakePage({ params }: NewIntakePageProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { ChevronRight, ChevronLeft } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { Report } from '@/lib/types/report'
|
||||
import { ReportComposer } from './report-composer'
|
||||
import { QuickActions, type ReportType } from './quick-actions'
|
||||
import { ReportTimeline } from './report-timeline'
|
||||
|
||||
const ReportViewEditModal = dynamic(
|
||||
() => import('./report-view-edit-modal').then((m) => m.ReportViewEditModal),
|
||||
{ ssr: false, loading: () => <ModalSkeleton /> }
|
||||
)
|
||||
|
||||
function ModalSkeleton() {
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/30">
|
||||
<div className="rounded-2xl bg-white/80 p-6 shadow-xl">
|
||||
<div className="h-4 w-48 rounded bg-slate-200 animate-pulse mb-4" />
|
||||
<div className="h-32 w-72 rounded bg-slate-100 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Types
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface RapportageWorkspaceV2Props {
|
||||
patientId: string
|
||||
patientName: string
|
||||
initialReports: Report[]
|
||||
}
|
||||
|
||||
type PanelsModule = typeof import('react-resizable-panels')
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Component
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function RapportageWorkspaceV2({
|
||||
patientId,
|
||||
patientName,
|
||||
initialReports,
|
||||
}: RapportageWorkspaceV2Props) {
|
||||
const [reports, setReports] = useState(initialReports)
|
||||
const [selectedType, setSelectedType] = useState<ReportType>('vrije_notitie')
|
||||
const [selectedReport, setSelectedReport] = useState<Report | null>(null)
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [duplicateContent, setDuplicateContent] = useState<string | null>(null)
|
||||
const [isComposerCollapsed, setIsComposerCollapsed] = useState(false)
|
||||
const [panelsLib, setPanelsLib] = useState<PanelsModule | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
import('react-resizable-panels')
|
||||
.then((mod) => {
|
||||
if (active) {
|
||||
setPanelsLib(mod)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Kon react-resizable-panels niet laden', error)
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Handlers
|
||||
const handleReportCreated = useCallback((report: Report) => {
|
||||
setReports((prev) => [report, ...prev])
|
||||
}, [])
|
||||
|
||||
const handleSelectReport = useCallback((report: Report) => {
|
||||
setSelectedReport(report)
|
||||
setIsModalOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleCloseModal = useCallback(() => {
|
||||
setIsModalOpen(false)
|
||||
}, [])
|
||||
|
||||
const handleReportUpdated = useCallback((updatedReport: Report) => {
|
||||
setReports((prev) =>
|
||||
prev.map((r) => (r.id === updatedReport.id ? updatedReport : r))
|
||||
)
|
||||
setSelectedReport(updatedReport)
|
||||
}, [])
|
||||
|
||||
const handleReportDeleted = useCallback((reportId: string) => {
|
||||
setReports((prev) => prev.filter((r) => r.id !== reportId))
|
||||
if (selectedReport?.id === reportId) {
|
||||
setSelectedReport(null)
|
||||
}
|
||||
}, [selectedReport])
|
||||
|
||||
const handleDuplicate = useCallback((content: string) => {
|
||||
setDuplicateContent(content)
|
||||
// Open composer als deze collapsed is
|
||||
if (isComposerCollapsed) {
|
||||
setIsComposerCollapsed(false)
|
||||
}
|
||||
}, [isComposerCollapsed])
|
||||
|
||||
const handleTypeSelect = useCallback((type: ReportType) => {
|
||||
setSelectedType(type)
|
||||
}, [])
|
||||
|
||||
const toggleComposer = useCallback(() => {
|
||||
setIsComposerCollapsed((prev) => !prev)
|
||||
}, [])
|
||||
|
||||
const PanelGroupComponent = panelsLib?.PanelGroup
|
||||
const PanelComponent = panelsLib?.Panel
|
||||
const PanelResizeHandleComponent = panelsLib?.PanelResizeHandle
|
||||
|
||||
const composerPanel = (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="px-4 py-3 border-b border-slate-100 shrink-0 bg-slate-50">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-semibold text-slate-700">Nieuwe rapportage</h2>
|
||||
{PanelGroupComponent ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleComposer}
|
||||
className="p-1.5 text-slate-400 hover:text-slate-600 hover:bg-slate-200 rounded-md transition-colors"
|
||||
aria-label="Sluit editor"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-xs text-slate-400">Layout optimalisatie laden…</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<QuickActions onSelectType={handleTypeSelect} selectedType={selectedType} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<ReportComposer
|
||||
patientId={patientId}
|
||||
patientName={patientName}
|
||||
selectedReport={selectedReport}
|
||||
onReportCreated={handleReportCreated}
|
||||
initialContent={duplicateContent}
|
||||
onInitialContentConsumed={() => setDuplicateContent(null)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col bg-slate-50">
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{PanelGroupComponent && PanelComponent && PanelResizeHandleComponent ? (
|
||||
<PanelGroupComponent direction="horizontal" className="h-full">
|
||||
<PanelComponent
|
||||
defaultSize={isComposerCollapsed ? 100 : 60}
|
||||
minSize={40}
|
||||
className="relative"
|
||||
>
|
||||
<ReportTimeline
|
||||
reports={reports}
|
||||
onSelectReport={handleSelectReport}
|
||||
activeReportId={selectedReport?.id}
|
||||
/>
|
||||
</PanelComponent>
|
||||
|
||||
{!isComposerCollapsed && (
|
||||
<PanelResizeHandleComponent className="relative w-1 bg-slate-200 hover:bg-emerald-400 transition-colors group">
|
||||
<div className="absolute inset-y-0 left-1/2 -translate-x-1/2 w-1 group-hover:w-1.5 bg-slate-300 group-hover:bg-emerald-500 transition-all" />
|
||||
</PanelResizeHandleComponent>
|
||||
)}
|
||||
|
||||
{!isComposerCollapsed && (
|
||||
<PanelComponent
|
||||
defaultSize={40}
|
||||
minSize={25}
|
||||
maxSize={60}
|
||||
className="relative bg-white border-l border-slate-200"
|
||||
>
|
||||
{composerPanel}
|
||||
</PanelComponent>
|
||||
)}
|
||||
|
||||
{isComposerCollapsed && (
|
||||
<div className="absolute top-1/2 right-0 -translate-y-1/2 z-20">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleComposer}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-6 rounded-l-lg',
|
||||
'bg-emerald-500 text-white shadow-lg',
|
||||
'hover:bg-emerald-600 transition-all',
|
||||
'border border-r-0 border-emerald-600'
|
||||
)}
|
||||
aria-label="Open editor"
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
<span className="text-sm font-medium writing-mode-vertical-rl rotate-180">
|
||||
Nieuwe rapportage
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</PanelGroupComponent>
|
||||
) : (
|
||||
<div className="h-full flex flex-col lg:flex-row">
|
||||
<div className="flex-1 overflow-y-auto border-b border-slate-200">
|
||||
<ReportTimeline
|
||||
reports={reports}
|
||||
onSelectReport={handleSelectReport}
|
||||
activeReportId={selectedReport?.id}
|
||||
/>
|
||||
</div>
|
||||
<div className="lg:w-[420px] border-t border-slate-200 lg:border-t-0 lg:border-l lg:border-slate-200 bg-white">
|
||||
{composerPanel}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isModalOpen && selectedReport && (
|
||||
<ReportViewEditModal
|
||||
report={selectedReport}
|
||||
isOpen={isModalOpen}
|
||||
onClose={handleCloseModal}
|
||||
patientId={patientId}
|
||||
onReportUpdated={handleReportUpdated}
|
||||
onReportDeleted={handleReportDeleted}
|
||||
onDuplicate={handleDuplicate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,18 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState, useEffect } from 'react';
|
||||
import { useMemo, useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { SpeechRecorder } from '@/components/speech-recorder';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import type { ClassificationResult, Report } from '@/lib/types/report';
|
||||
import { createReport } from '../actions';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const SpeechRecorderStreaming = dynamic(
|
||||
() => import('@/components/speech-recorder-streaming').then((m) => m.SpeechRecorderStreaming),
|
||||
{ ssr: false, loading: () => <RecorderSkeleton /> }
|
||||
);
|
||||
|
||||
function RecorderSkeleton() {
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-4 animate-pulse">
|
||||
<div className="h-4 w-1/2 rounded bg-slate-100 mb-2" />
|
||||
<div className="h-10 rounded bg-slate-100" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ReportComposerProps {
|
||||
patientId: string;
|
||||
patientName: string;
|
||||
selectedReport?: Report | null;
|
||||
onReportCreated?: (report: Report) => void;
|
||||
/** Initial content voor de editor (bijv. van duplicate) */
|
||||
initialContent?: string | null;
|
||||
/** Callback wanneer initialContent is verwerkt */
|
||||
onInitialContentConsumed?: () => void;
|
||||
}
|
||||
|
||||
export function ReportComposer({
|
||||
@@ -20,8 +39,11 @@ export function ReportComposer({
|
||||
patientName,
|
||||
selectedReport,
|
||||
onReportCreated,
|
||||
initialContent,
|
||||
onInitialContentConsumed,
|
||||
}: ReportComposerProps) {
|
||||
const router = useRouter();
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [content, setContent] = useState('');
|
||||
const [classification, setClassification] = useState<ClassificationResult | null>(null);
|
||||
const [selectedType, setSelectedType] = useState<'behandeladvies' | 'vrije_notitie'>('vrije_notitie');
|
||||
@@ -29,8 +51,44 @@ export function ReportComposer({
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastAutosave, setLastAutosave] = useState<Date | null>(null);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [interimText, setInterimText] = useState('');
|
||||
const draftStorageKey = useMemo(() => `rapportage-draft-${patientId}`, [patientId]);
|
||||
|
||||
// Handle initialContent (e.g., from duplicate)
|
||||
useEffect(() => {
|
||||
if (initialContent) {
|
||||
setContent(initialContent);
|
||||
onInitialContentConsumed?.();
|
||||
// Focus en scroll naar einde
|
||||
setTimeout(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.focus();
|
||||
textareaRef.current.scrollTop = textareaRef.current.scrollHeight;
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}, [initialContent, onInitialContentConsumed]);
|
||||
|
||||
// Cursor naar einde verplaatsen bij start opname
|
||||
const handleRecordingStart = useCallback(() => {
|
||||
setIsStreaming(true);
|
||||
if (textareaRef.current) {
|
||||
const textarea = textareaRef.current;
|
||||
textarea.focus();
|
||||
// Verplaats cursor naar het einde
|
||||
const length = textarea.value.length;
|
||||
textarea.setSelectionRange(length, length);
|
||||
// Scroll naar beneden
|
||||
textarea.scrollTop = textarea.scrollHeight;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRecordingStop = useCallback(() => {
|
||||
setIsStreaming(false);
|
||||
setInterimText('');
|
||||
}, []);
|
||||
|
||||
const characterCount = content.length;
|
||||
const contentInvalid = characterCount < 20 || characterCount > 5000;
|
||||
|
||||
@@ -170,16 +228,8 @@ export function ReportComposer({
|
||||
<section
|
||||
id="rapportage-composer"
|
||||
tabIndex={-1}
|
||||
className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm focus:outline-none"
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="mb-4">
|
||||
<p className="text-sm uppercase tracking-wide text-slate-500">Nieuwe rapportage</p>
|
||||
<h2 className="text-xl font-semibold text-slate-900">Voor {patientName}</h2>
|
||||
<p className="text-sm text-slate-500">
|
||||
Schrijf vanuit tekst of spraak en gebruik AI voor typebepaling.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{referenceSnippet && (
|
||||
<div className="mb-4 rounded-xl border border-slate-100 bg-slate-50 p-4">
|
||||
<div className="flex items-center justify-between gap-2 text-xs text-slate-500">
|
||||
@@ -205,22 +255,41 @@ export function ReportComposer({
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="Beschrijf wat je wilt vastleggen..."
|
||||
className="w-full min-h-[200px] rounded-xl border border-slate-200 bg-white p-4 text-sm text-slate-900 shadow-inner focus:border-teal-500 focus:outline-none"
|
||||
className={cn(
|
||||
'w-full min-h-[200px] rounded-xl border bg-white p-4 text-sm text-slate-900 shadow-inner focus:outline-none transition-all duration-200',
|
||||
isStreaming
|
||||
? 'border-emerald-500 border-2 shadow-emerald-500/20 shadow-md'
|
||||
: 'border-slate-200 focus:border-teal-500'
|
||||
)}
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-slate-500">
|
||||
{/* Interim tekst preview tijdens streaming */}
|
||||
{interimText && (
|
||||
<div className="mt-1 text-sm text-slate-500 italic px-1">
|
||||
{interimText}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between text-xs text-slate-500 mt-1">
|
||||
<span>{characterCount} / 5000 karakters</span>
|
||||
{contentInvalid && <span>Minimaal 20 karakters</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SpeechRecorder
|
||||
<SpeechRecorderStreaming
|
||||
disabled={isSaving || isAnalyzing}
|
||||
onTranscript={(text) =>
|
||||
setContent((prev) => (prev ? `${prev}\n${text}` : text))
|
||||
setContent((prev) => (prev ? `${prev} ${text}` : text))
|
||||
}
|
||||
onInterimTranscript={setInterimText}
|
||||
onRecordingStart={handleRecordingStart}
|
||||
onRecordingStop={handleRecordingStop}
|
||||
telemetryContext={{
|
||||
context: 'report_composer',
|
||||
patientId,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="rounded-xl border border-slate-200 p-4 text-sm">
|
||||
|
||||
@@ -0,0 +1,547 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { X, Pencil, Copy, Trash2, Save, Loader2 } from 'lucide-react'
|
||||
import { format, formatDistanceToNow } from 'date-fns'
|
||||
import { nl } from 'date-fns/locale'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { Report } from '@/lib/types/report'
|
||||
import { SpeechRecorderStreaming } from '@/components/speech-recorder-streaming'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
import { updateReport, deleteReport } from '../actions'
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Types
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ReportViewEditModalProps {
|
||||
/** Het rapport om te bekijken/bewerken */
|
||||
report: Report | null
|
||||
/** Of de modal open is */
|
||||
isOpen: boolean
|
||||
/** Callback om modal te sluiten */
|
||||
onClose: () => void
|
||||
/** Patient ID voor API calls */
|
||||
patientId: string
|
||||
/** Callback na succesvolle update */
|
||||
onReportUpdated?: (report: Report) => void
|
||||
/** Callback na verwijdering */
|
||||
onReportDeleted?: (reportId: string) => void
|
||||
/** Callback voor dupliceren */
|
||||
onDuplicate?: (content: string) => void
|
||||
}
|
||||
|
||||
type ModalMode = 'read' | 'edit'
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Unsaved Changes Dialog
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function UnsavedChangesDialog({
|
||||
isOpen,
|
||||
onSaveAndClose,
|
||||
onDiscardAndClose,
|
||||
onCancel,
|
||||
isSaving,
|
||||
}: {
|
||||
isOpen: boolean
|
||||
onSaveAndClose: () => void
|
||||
onDiscardAndClose: () => void
|
||||
onCancel: () => void
|
||||
isSaving: boolean
|
||||
}) {
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50">
|
||||
<div className="bg-white rounded-xl shadow-2xl p-6 max-w-md mx-4 animate-in fade-in zoom-in-95 duration-200">
|
||||
<h3 className="text-lg font-semibold text-slate-900">
|
||||
Niet opgeslagen wijzigingen
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-slate-600">
|
||||
Je hebt wijzigingen die nog niet zijn opgeslagen. Wat wil je doen?
|
||||
</p>
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSaveAndClose}
|
||||
disabled={isSaving}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-emerald-600 text-white rounded-lg font-medium hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Opslaan...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-4 w-4" />
|
||||
Opslaan en sluiten
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDiscardAndClose}
|
||||
disabled={isSaving}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2 border border-red-200 text-red-600 rounded-lg font-medium hover:bg-red-50 disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Wijzigingen verwijderen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={isSaving}
|
||||
className="w-full px-4 py-2 text-slate-600 rounded-lg font-medium hover:bg-slate-100 disabled:opacity-50"
|
||||
>
|
||||
← Terug naar bewerken
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Delete Confirmation Dialog
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function DeleteConfirmDialog({
|
||||
isOpen,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
isDeleting,
|
||||
}: {
|
||||
isOpen: boolean
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
isDeleting: boolean
|
||||
}) {
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50">
|
||||
<div className="bg-white rounded-xl shadow-2xl p-6 max-w-md mx-4 animate-in fade-in zoom-in-95 duration-200">
|
||||
<h3 className="text-lg font-semibold text-slate-900">
|
||||
Rapportage verwijderen?
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-slate-600">
|
||||
Weet je zeker dat je deze rapportage wilt verwijderen? Dit kan niet ongedaan worden gemaakt.
|
||||
</p>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={isDeleting}
|
||||
className="flex-1 px-4 py-2 border border-slate-200 text-slate-700 rounded-lg font-medium hover:bg-slate-50 disabled:opacity-50"
|
||||
>
|
||||
Annuleren
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={isDeleting}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Verwijderen...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Verwijderen
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Main Modal Component
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function ReportViewEditModal({
|
||||
report,
|
||||
isOpen,
|
||||
onClose,
|
||||
patientId,
|
||||
onReportUpdated,
|
||||
onReportDeleted,
|
||||
onDuplicate,
|
||||
}: ReportViewEditModalProps) {
|
||||
// State
|
||||
const [mode, setMode] = useState<ModalMode>('read')
|
||||
const [content, setContent] = useState('')
|
||||
const [originalContent, setOriginalContent] = useState('')
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [showUnsavedDialog, setShowUnsavedDialog] = useState(false)
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [isStreaming, setIsStreaming] = useState(false)
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
// Sync content met report
|
||||
useEffect(() => {
|
||||
if (report) {
|
||||
setContent(report.content)
|
||||
setOriginalContent(report.content)
|
||||
setMode('read')
|
||||
}
|
||||
}, [report])
|
||||
|
||||
// Reset bij sluiten
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setMode('read')
|
||||
setShowUnsavedDialog(false)
|
||||
setShowDeleteDialog(false)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
// Check for unsaved changes
|
||||
const hasUnsavedChanges = content !== originalContent
|
||||
|
||||
// Keyboard handler (Escape)
|
||||
// Handlers
|
||||
const handleClose = useCallback(() => {
|
||||
if (mode === 'edit' && hasUnsavedChanges) {
|
||||
setShowUnsavedDialog(true)
|
||||
} else {
|
||||
onClose()
|
||||
}
|
||||
}, [mode, hasUnsavedChanges, onClose])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
handleClose()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [isOpen, handleClose])
|
||||
|
||||
const handleEdit = useCallback(() => {
|
||||
setMode('edit')
|
||||
// Focus textarea en zet cursor aan einde
|
||||
setTimeout(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.focus()
|
||||
const length = textareaRef.current.value.length
|
||||
textareaRef.current.setSelectionRange(length, length)
|
||||
}
|
||||
}, 100)
|
||||
}, [])
|
||||
|
||||
const handleCancelEdit = useCallback(() => {
|
||||
if (hasUnsavedChanges) {
|
||||
setShowUnsavedDialog(true)
|
||||
} else {
|
||||
setContent(originalContent)
|
||||
setMode('read')
|
||||
}
|
||||
}, [hasUnsavedChanges, originalContent])
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!report) return
|
||||
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const updated = await updateReport(patientId, report.id, { content })
|
||||
setOriginalContent(content)
|
||||
onReportUpdated?.(updated)
|
||||
toast({
|
||||
title: 'Wijzigingen opgeslagen',
|
||||
description: 'De rapportage is bijgewerkt.',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to save:', error)
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Opslaan mislukt',
|
||||
description: error instanceof Error ? error.message : 'Probeer het opnieuw.',
|
||||
})
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}, [report, patientId, content, onReportUpdated])
|
||||
|
||||
const handleSaveAndClose = useCallback(async () => {
|
||||
await handleSave()
|
||||
setShowUnsavedDialog(false)
|
||||
onClose()
|
||||
}, [handleSave, onClose])
|
||||
|
||||
const handleDiscardAndClose = useCallback(() => {
|
||||
setContent(originalContent)
|
||||
setShowUnsavedDialog(false)
|
||||
onClose()
|
||||
}, [originalContent, onClose])
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!report) return
|
||||
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
await deleteReport(patientId, report.id)
|
||||
onReportDeleted?.(report.id)
|
||||
toast({
|
||||
title: 'Rapportage verwijderd',
|
||||
description: 'De rapportage is verwijderd.',
|
||||
})
|
||||
onClose()
|
||||
} catch (error) {
|
||||
console.error('Failed to delete:', error)
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Verwijderen mislukt',
|
||||
description: error instanceof Error ? error.message : 'Probeer het opnieuw.',
|
||||
})
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
setShowDeleteDialog(false)
|
||||
}
|
||||
}, [report, patientId, onReportDeleted, onClose])
|
||||
|
||||
const handleDuplicate = useCallback(() => {
|
||||
onDuplicate?.(content)
|
||||
onClose()
|
||||
toast({
|
||||
title: 'Gekopieerd',
|
||||
description: 'Inhoud is gekopieerd naar de editor.',
|
||||
})
|
||||
}, [content, onDuplicate, onClose])
|
||||
|
||||
const handleTranscript = useCallback((transcript: string) => {
|
||||
setContent((prev) => (prev ? `${prev} ${transcript}` : transcript))
|
||||
}, [])
|
||||
|
||||
const handleRecordingStart = useCallback(() => {
|
||||
setIsStreaming(true)
|
||||
if (textareaRef.current) {
|
||||
const length = textareaRef.current.value.length
|
||||
textareaRef.current.setSelectionRange(length, length)
|
||||
textareaRef.current.scrollTop = textareaRef.current.scrollHeight
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleRecordingStop = useCallback(() => {
|
||||
setIsStreaming(false)
|
||||
}, [])
|
||||
|
||||
// Don't render if not open or no report
|
||||
if (!isOpen || !report) return null
|
||||
|
||||
const createdAt = report.created_at ? new Date(report.created_at) : null
|
||||
const dateFormatted = createdAt
|
||||
? format(createdAt, "d MMMM yyyy 'om' HH:mm", { locale: nl })
|
||||
: ''
|
||||
const relativeTime = createdAt
|
||||
? formatDistanceToNow(createdAt, { addSuffix: true, locale: nl })
|
||||
: ''
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
behandeladvies: 'Behandeladvies',
|
||||
vrije_notitie: 'Vrije notitie',
|
||||
intake_verslag: 'Intake verslag',
|
||||
behandelplan: 'Behandelplan',
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/30 animate-in fade-in duration-200"
|
||||
onClick={handleClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center p-4 pt-[10vh] overflow-y-auto">
|
||||
<div
|
||||
className={cn(
|
||||
'bg-white rounded-2xl shadow-2xl w-full max-w-3xl',
|
||||
'animate-in fade-in slide-in-from-bottom-4 duration-300'
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between p-6 border-b border-slate-200">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-900">
|
||||
{TYPE_LABELS[report.type] || report.type}
|
||||
{mode === 'edit' && (
|
||||
<span className="ml-2 text-sm font-normal text-emerald-600">
|
||||
(bewerken)
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
{dateFormatted}
|
||||
{relativeTime && <span className="ml-2 text-slate-400">({relativeTime})</span>}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{mode === 'read' ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleEdit}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-slate-700 bg-white border border-slate-200 rounded-lg hover:bg-slate-50"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
Bewerken
|
||||
</button>
|
||||
{onDuplicate && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDuplicate}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-slate-700 bg-white border border-slate-200 rounded-lg hover:bg-slate-50"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
Dupliceer
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-red-600 bg-white border border-red-200 rounded-lg hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || !hasUnsavedChanges}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-white bg-emerald-600 rounded-lg hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Opslaan...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-4 w-4" />
|
||||
Opslaan
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancelEdit}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-slate-700 bg-white border border-slate-200 rounded-lg hover:bg-slate-50"
|
||||
>
|
||||
Annuleren
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg"
|
||||
aria-label="Sluiten"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Speech Recorder (only in edit mode) */}
|
||||
{mode === 'edit' && (
|
||||
<div className="p-6 border-b border-slate-100 bg-slate-50">
|
||||
<SpeechRecorderStreaming
|
||||
onTranscript={handleTranscript}
|
||||
onRecordingStart={handleRecordingStart}
|
||||
onRecordingStop={handleRecordingStop}
|
||||
telemetryContext={{
|
||||
context: 'report_modal',
|
||||
patientId,
|
||||
reportId: report.id,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
{mode === 'read' ? (
|
||||
<div className="prose prose-slate max-w-none">
|
||||
<p className="whitespace-pre-wrap text-slate-700 leading-relaxed">
|
||||
{content}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
className={cn(
|
||||
'w-full min-h-[300px] p-4 text-sm text-slate-900 rounded-xl border bg-white',
|
||||
'focus:outline-none transition-all duration-200',
|
||||
isStreaming
|
||||
? 'border-emerald-500 border-2 shadow-emerald-500/20 shadow-md'
|
||||
: 'border-slate-200 focus:border-emerald-300'
|
||||
)}
|
||||
placeholder="Bewerk de rapportage of dicteer met spraak..."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer with metadata */}
|
||||
{report.ai_reasoning && (
|
||||
<div className="px-6 pb-6">
|
||||
<div className="rounded-lg bg-slate-50 p-4 text-sm">
|
||||
<p className="font-medium text-slate-700">AI toelichting</p>
|
||||
<p className="mt-1 text-slate-600">{report.ai_reasoning}</p>
|
||||
{report.ai_confidence !== null && (
|
||||
<p className="mt-2 text-xs text-slate-500">
|
||||
Zekerheid: {Math.round(report.ai_confidence * 100)}%
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Unsaved indicator */}
|
||||
{mode === 'edit' && hasUnsavedChanges && (
|
||||
<div className="px-6 pb-4">
|
||||
<p className="text-xs text-amber-600 flex items-center gap-1">
|
||||
<span className="w-2 h-2 rounded-full bg-amber-500" />
|
||||
Niet opgeslagen wijzigingen
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dialogs */}
|
||||
<UnsavedChangesDialog
|
||||
isOpen={showUnsavedDialog}
|
||||
onSaveAndClose={handleSaveAndClose}
|
||||
onDiscardAndClose={handleDiscardAndClose}
|
||||
onCancel={() => setShowUnsavedDialog(false)}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
|
||||
<DeleteConfirmDialog
|
||||
isOpen={showDeleteDialog}
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setShowDeleteDialog(false)}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
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 { 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 { Brain, Zap, Target, Clock } from 'lucide-react'
|
||||
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 = [
|
||||
{
|
||||
name: 'AI Intake Samenvatting',
|
||||
@@ -56,144 +50,10 @@ const bentoFeatures = [
|
||||
]
|
||||
|
||||
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 (
|
||||
<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="max-w-5xl mx-auto w-full">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<a href="/" className="inline-block mb-6 text-slate-300 hover:text-white transition-colors">
|
||||
← Terug naar home
|
||||
@@ -206,11 +66,10 @@ export default function LoginPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Bento Grid */}
|
||||
<BentoGrid className="mb-8">
|
||||
{bentoFeatures.map((feature, index) => (
|
||||
{bentoFeatures.map((feature) => (
|
||||
<BentoCard
|
||||
key={index}
|
||||
key={feature.name}
|
||||
name={feature.name}
|
||||
description={feature.description}
|
||||
Icon={feature.icon}
|
||||
@@ -222,7 +81,6 @@ export default function LoginPage() {
|
||||
))}
|
||||
</BentoGrid>
|
||||
|
||||
{/* Stats Footer */}
|
||||
<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-2xl md:text-3xl font-bold text-amber-400">90%+</div>
|
||||
@@ -240,202 +98,7 @@ export default function LoginPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Side - Login/Signup Form (40%) */}
|
||||
<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>
|
||||
<LoginForm />
|
||||
</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 { 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: {
|
||||
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: {
|
||||
Tables: {
|
||||
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: {
|
||||
Row: {
|
||||
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: {
|
||||
Row: {
|
||||
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: {
|
||||
active_intakes_overview: {
|
||||
@@ -1646,9 +1643,6 @@ export type CompositeTypes<
|
||||
: never
|
||||
|
||||
export const Constants = {
|
||||
graphql_public: {
|
||||
Enums: {},
|
||||
},
|
||||
public: {
|
||||
Enums: {
|
||||
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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -5,12 +5,14 @@
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"check:bundle": "node scripts/check-bundle-size.js",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"types:generate": "npx supabase gen types typescript --project-id dqugbrpwtisgyxscpefg > lib/supabase/types.ts",
|
||||
"setup:auth-hook": "tsx scripts/setup-auth-hook.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@deepgram/sdk": "^4.11.2",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
@@ -36,6 +38,7 @@
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-hook-form": "^7.66.1",
|
||||
"react-resizable-panels": "^3.0.6",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"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