feat: rapportage API, speech recorder refactor, docs reorganisatie

Multiple features and improvements:

Reports/Rapportage API:
- Created REST API endpoints: /api/reports (GET/POST), /api/reports/[id] (GET/PATCH/DELETE)
- Added /api/reports/classify endpoint for AI classification
- Supabase migrations: reports table + RLS policies
- Server utilities: api-client.ts for DRY fetch logic
- Type definitions: lib/types/report.ts with Zod schemas
- Removed old rapportage-modal component (replaced by split-view)

Speech Recorder Refactor:
- Moved speech-recorder from intake-specific to shared components/
- Updated treatment-advice-form to use new location
- Updated intake actions for speech functionality

UI Components (shadcn):
- Added dialog, dropdown-menu, toast, toaster components
- Added use-toast hook for toast notifications

Documentation:
- Reorganized docs/release/ → docs/reports/ for better structure
- Archived old specs to docs/specs/archive/
- Added screening-system.mdx documentation
- Added rapportage-split-view-design.md
- Added UI screenshots for troubleshooting

Dependencies:
- Updated package.json and pnpm-lock.yaml
- Regenerated database.types.ts from Supabase

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
colinislit
2025-11-24 14:14:06 +01:00
parent 0130e28f1e
commit a789bebe96
37 changed files with 5034 additions and 1977 deletions

View File

@@ -0,0 +1,138 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { createClient } from '@/lib/auth/server';
const uuidSchema = z.string().uuid('reportId moet een geldige UUID zijn');
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ reportId: string }> }
) {
try {
const { reportId } = await params;
if (!uuidSchema.safeParse(reportId).success) {
return NextResponse.json(
{ error: 'reportId moet een geldige UUID zijn' },
{ status: 400 }
);
}
const supabase = await createClient();
const { data, error } = await supabase
.from('reports')
.select('*')
.eq('id', reportId)
.is('deleted_at', null)
.single();
if (error) {
if (error.code === 'PGRST116') {
return NextResponse.json({ error: 'Rapport niet gevonden' }, { status: 404 });
}
console.error('Error fetching report:', error);
return NextResponse.json(
{ error: 'Fout bij ophalen rapport', details: error.message },
{ status: 500 }
);
}
return NextResponse.json(data);
} catch (error) {
console.error('Unexpected error in GET /api/reports/[reportId]:', error);
return NextResponse.json(
{ error: 'Onverwachte serverfout' },
{ status: 500 }
);
}
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ reportId: string }> }
) {
try {
const { reportId } = await params;
const body = await request.json();
if (!uuidSchema.safeParse(reportId).success) {
return NextResponse.json(
{ error: 'reportId moet een geldige UUID zijn' },
{ status: 400 }
);
}
const supabase = await createClient();
const { data, error } = await supabase
.from('reports')
.update({ ...body, updated_at: new Date().toISOString() })
.eq('id', reportId)
.select('*')
.single();
if (error) {
if (error.code === 'PGRST116') {
return NextResponse.json({ error: 'Rapport niet gevonden' }, { status: 404 });
}
console.error('Error updating report:', error);
return NextResponse.json(
{ error: 'Fout bij updaten rapport', details: error.message },
{ status: 500 }
);
}
return NextResponse.json(data);
} catch (error) {
console.error('Unexpected error in PATCH /api/reports/[reportId]:', error);
if (error instanceof SyntaxError) {
return NextResponse.json(
{ error: 'Ongeldige JSON in request body' },
{ status: 400 }
);
}
return NextResponse.json(
{ error: 'Onverwachte serverfout' },
{ status: 500 }
);
}
}
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ reportId: string }> }
) {
try {
const { reportId } = await params;
if (!uuidSchema.safeParse(reportId).success) {
return NextResponse.json(
{ error: 'reportId moet een geldige UUID zijn' },
{ status: 400 }
);
}
const supabase = await createClient();
const { error } = await supabase
.from('reports')
.update({ deleted_at: new Date().toISOString() })
.eq('id', reportId);
if (error) {
console.error('Error deleting report:', error);
return NextResponse.json(
{ error: 'Verwijderen mislukt', details: error.message },
{ status: 500 }
);
}
return NextResponse.json({ success: true });
} catch (error) {
console.error('Unexpected error in DELETE /api/reports/[reportId]:', error);
return NextResponse.json(
{ error: 'Onverwachte serverfout' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,79 @@
import { NextRequest, NextResponse } from 'next/server';
const CLASSIFICATION_PROMPT = `
Je bent een classificatie-assistent voor een GGZ EPD-systeem.
Classificeer de volgende rapportage als één van deze types:
**Behandeladvies:**
- Bevat een concreet behandelplan of voorstel
- Noemt doelen, interventies, aanpak
- Woorden zoals: "advies", "plan", "traject", "sessies", "behandeling"
**Vrije notitie:**
- Alles wat niet duidelijk een behandeladvies is
- Algemene observaties, opmerkingen, aantekeningen
Return ALLEEN JSON:
{
"type": "behandeladvies" | "vrije_notitie",
"confidence": 0.0-1.0,
"reasoning": "optionele uitleg"
}`;
export async function POST(request: NextRequest) {
try {
const { content } = await request.json();
if (!content || content.length < 20) {
return NextResponse.json(
{ error: 'Content moet minimaal 20 karakters bevatten' },
{ status: 400 }
);
}
const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) {
return NextResponse.json(
{ error: 'ANTHROPIC_API_KEY ontbreekt' },
{ status: 500 }
);
}
const anthropicResponse = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: 'claude-3-5-sonnet-20240620',
max_tokens: 256,
temperature: 0,
system: CLASSIFICATION_PROMPT,
messages: [
{ role: 'user', content: content.trim() },
],
}),
});
if (!anthropicResponse.ok) {
const errorBody = await anthropicResponse.text();
throw new Error(`Claude API error: ${errorBody}`);
}
const data = await anthropicResponse.json();
const rawText = data?.content?.[0]?.text ?? '';
const parsed = JSON.parse(rawText);
return NextResponse.json(parsed);
} catch (error) {
console.error('AI classification error:', error);
return NextResponse.json({
type: 'vrije_notitie',
confidence: 0.5,
reasoning: 'AI classificatie mislukt. Kies handmatig een type.',
});
}
}

120
app/api/reports/route.ts Normal file
View File

@@ -0,0 +1,120 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { createClient } from '@/lib/auth/server';
import { CreateReportSchema, type ReportListResponse } from '@/lib/types/report';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const patientId = searchParams.get('patientId');
if (!patientId) {
return NextResponse.json(
{ error: 'patientId query parameter is verplicht' },
{ status: 400 }
);
}
if (!z.string().uuid().safeParse(patientId).success) {
return NextResponse.json(
{ error: 'patientId moet een geldige UUID zijn' },
{ status: 400 }
);
}
const supabase = await createClient();
const { data, error } = await supabase
.from('reports')
.select('*')
.eq('patient_id', patientId)
.is('deleted_at', null)
.order('created_at', { ascending: false });
if (error) {
console.error('Error fetching reports:', error);
return NextResponse.json(
{ error: 'Fout bij ophalen rapportages', details: error.message },
{ status: 500 }
);
}
const response: ReportListResponse = {
reports: data ?? [],
total: data?.length ?? 0,
};
return NextResponse.json(response);
} catch (error) {
console.error('Unexpected error in GET /api/reports:', error);
return NextResponse.json(
{ error: 'Onverwachte serverfout' },
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const result = CreateReportSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{
error: 'Validatiefout',
details: result.error.issues.map((issue) => ({
field: issue.path.join('.'),
message: issue.message,
})),
},
{ status: 400 }
);
}
const supabase = await createClient();
const { data: authData } = await supabase.auth.getUser();
if (!authData?.user) {
return NextResponse.json(
{ error: 'Niet geautoriseerd' },
{ status: 401 }
);
}
const { patient_id, type, content, ai_confidence, ai_reasoning } = result.data;
const { data, error } = await supabase
.from('reports')
.insert({
patient_id,
type,
content,
ai_confidence,
ai_reasoning,
created_by: authData.user.id,
})
.select('*')
.single();
if (error) {
console.error('Error creating report:', error);
return NextResponse.json(
{ error: 'Opslaan mislukt', details: error.message },
{ status: 500 }
);
}
return NextResponse.json(data, { status: 201 });
} catch (error) {
console.error('Unexpected error in POST /api/reports:', error);
if (error instanceof SyntaxError) {
return NextResponse.json(
{ error: 'Ongeldige JSON in request body' },
{ status: 400 }
);
}
return NextResponse.json(
{ error: 'Onverwachte serverfout' },
{ status: 500 }
);
}
}

View File

@@ -5,7 +5,7 @@ import { saveTreatmentAdvice } from '../../actions';
import { Loader2, Calendar, UserCircle, ClipboardList, Share2, CheckCircle2 } from 'lucide-react'; import { Loader2, Calendar, UserCircle, ClipboardList, Share2, CheckCircle2 } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { RichTextEditor } from '@/components/rich-text-editor'; import { RichTextEditor } from '@/components/rich-text-editor';
import { SpeechRecorder } from './speech-recorder'; import { SpeechRecorder } from '@/components/speech-recorder';
interface AdviceData { interface AdviceData {
advice?: string; advice?: string;

View File

@@ -9,7 +9,7 @@
import { revalidatePath } from 'next/cache'; import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { headers, cookies } from 'next/headers'; import { authFetch, getBaseUrl } from '@/lib/server/api-client';
import type { import type {
Intake, Intake,
CreateIntakeInput, CreateIntakeInput,
@@ -21,42 +21,6 @@ import type {
* Get the base URL for API calls in server actions * Get the base URL for API calls in server actions
* Uses headers() to get the host from the request * Uses headers() to get the host from the request
*/ */
function getBaseUrl(): string {
// Try environment variable first
if (process.env.NEXT_PUBLIC_APP_URL) {
return process.env.NEXT_PUBLIC_APP_URL;
}
// Try to get from headers (works in server components/actions)
try {
const headersList = headers();
const host = headersList.get('host');
const protocol = headersList.get('x-forwarded-proto') || 'http';
if (host) {
return `${protocol}://${host}`;
}
} catch {
// Headers not available, fallback to localhost
}
// Fallback to localhost
return 'http://localhost:3000';
}
/**
* Get cookies as a string for fetch headers
*/
async function getCookieHeader(): Promise<string> {
try {
const cookieStore = await cookies();
return cookieStore
.getAll()
.map((cookie) => `${cookie.name}=${cookie.value}`)
.join('; ');
} catch {
return '';
}
}
/** /**
* Get all intakes for a specific patient * Get all intakes for a specific patient
@@ -69,13 +33,8 @@ export async function getIntakesByPatientId(patientId: string): Promise<Intake[]
const url = new URL('/api/intakes', baseUrl); const url = new URL('/api/intakes', baseUrl);
url.searchParams.set('patientId', patientId); url.searchParams.set('patientId', patientId);
const cookieHeader = await getCookieHeader(); const response = await authFetch(url.toString(), {
const response = await fetch(url.toString(), {
cache: 'no-store', cache: 'no-store',
headers: {
...(cookieHeader && { Cookie: cookieHeader }),
},
}); });
if (!response.ok) { if (!response.ok) {
@@ -112,13 +71,8 @@ export async function getIntakeById(intakeId: string): Promise<Intake | null> {
try { try {
const baseUrl = getBaseUrl(); const baseUrl = getBaseUrl();
const url = `${baseUrl}/api/intakes/${intakeId}`; const url = `${baseUrl}/api/intakes/${intakeId}`;
const cookieHeader = await getCookieHeader(); const response = await authFetch(url, {
const response = await fetch(url, {
cache: 'no-store', cache: 'no-store',
headers: {
...(cookieHeader && { Cookie: cookieHeader }),
},
}); });
if (response.status === 404) { if (response.status === 404) {
@@ -163,13 +117,10 @@ export async function createIntake(input: CreateIntakeInput): Promise<Intake> {
try { try {
const baseUrl = getBaseUrl(); const baseUrl = getBaseUrl();
const url = `${baseUrl}/api/intakes`; const url = `${baseUrl}/api/intakes`;
const cookieHeader = await getCookieHeader(); const response = await authFetch(url, {
const response = await fetch(url, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...(cookieHeader && { Cookie: cookieHeader }),
}, },
body: JSON.stringify(input), body: JSON.stringify(input),
}); });
@@ -230,13 +181,10 @@ export async function updateIntake(
try { try {
const baseUrl = getBaseUrl(); const baseUrl = getBaseUrl();
const url = `${baseUrl}/api/intakes/${intakeId}`; const url = `${baseUrl}/api/intakes/${intakeId}`;
const cookieHeader = await getCookieHeader(); const response = await authFetch(url, {
const response = await fetch(url, {
method: 'PUT', method: 'PUT',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...(cookieHeader && { Cookie: cookieHeader }),
}, },
body: JSON.stringify(input), body: JSON.stringify(input),
}); });
@@ -332,4 +280,3 @@ export async function deleteIntake(intakeId: string, patientId: string): Promise
throw error instanceof Error ? error : new Error('Failed to delete intake'); throw error instanceof Error ? error : new Error('Failed to delete intake');
} }
} }

View File

@@ -1,196 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { SpeechRecorder } from '@/components/speech-recorder';
import type { ClassificationResult } from '@/lib/types/report';
import { createReport } from '../actions';
import { toast } from '@/hooks/use-toast';
interface RapportageModalProps {
isOpen: boolean;
onClose: () => void;
patientId: string;
patientName: string;
}
export function RapportageModal({ isOpen, onClose, patientId, patientName }: RapportageModalProps) {
const [content, setContent] = useState('');
const [classification, setClassification] = useState<ClassificationResult | null>(null);
const [selectedType, setSelectedType] = useState<'behandeladvies' | 'vrije_notitie'>('vrije_notitie');
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const router = useRouter();
useEffect(() => {
if (!isOpen) {
setContent('');
setClassification(null);
setSelectedType('vrije_notitie');
setError(null);
setIsAnalyzing(false);
setIsSaving(false);
}
}, [isOpen]);
const handleAnalyze = async () => {
if (contentInvalid) return;
setIsAnalyzing(true);
setError(null);
try {
const response = await fetch('/api/reports/classify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ content }),
});
if (!response.ok) {
throw new Error('AI-analyse mislukt');
}
const result: ClassificationResult = await response.json();
setClassification(result);
setSelectedType(result.type);
} catch (err) {
setClassification(null);
setSelectedType('vrije_notitie');
const message = err instanceof Error ? err.message : 'AI-analyse mislukt';
setError(message);
toast({
variant: 'destructive',
title: 'AI-analyse mislukt',
description: message,
});
} finally {
setIsAnalyzing(false);
}
};
const handleSave = async () => {
setIsSaving(true);
setError(null);
try {
await createReport(patientId, {
type: selectedType,
content,
ai_confidence: classification?.confidence,
ai_reasoning: classification?.reasoning,
});
toast({
title: 'Rapportage opgeslagen',
description: `${patientName} heeft nu een nieuwe notitie in de tijdlijn.`,
});
router.refresh();
onClose();
} catch (err) {
const message = err instanceof Error ? err.message : 'Opslaan mislukt';
setError(message);
toast({
variant: 'destructive',
title: 'Opslaan mislukt',
description: message,
});
} finally {
setIsSaving(false);
}
};
const characterCount = content.length;
const contentInvalid = characterCount < 20 || characterCount > 5000;
return (
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open) {
onClose();
}
}}
>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Nieuwe rapportage</DialogTitle>
<DialogDescription>
Leg een rapportage vast voor {patientName}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Beschrijf wat je wilt vastleggen..."
className="w-full min-h-[160px] rounded-md border border-slate-200 p-3"
/>
<div className="flex justify-between text-xs text-slate-500">
<span>{characterCount} / 5000 karakters</span>
{contentInvalid && <span>Min. 20 karakters</span>}
</div>
</div>
<SpeechRecorder
disabled={isSaving || isAnalyzing}
onTranscript={(text) =>
setContent((prev) => `${prev}${prev ? '\n' : ''}${text}`)
}
/>
<div className="rounded-lg border border-slate-200 p-3 text-sm">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-slate-900">Rapportagetype</p>
<p className="text-xs text-slate-500">
Kies handmatig of gebruik het AI-voorstel.
</p>
</div>
<span className="text-xs text-slate-500">
{classification
? `AI: ${classification.type} (${Math.round(classification.confidence * 100)}%)`
: 'Nog geen AI-analyse'}
</span>
</div>
{classification?.reasoning && (
<p className="mt-2 text-xs text-slate-500">{classification.reasoning}</p>
)}
<select
value={selectedType}
onChange={(e) => setSelectedType(e.target.value as 'behandeladvies' | 'vrije_notitie')}
className="mt-3 w-full rounded-md border border-slate-200 bg-white p-2 text-sm"
>
<option value="behandeladvies">Behandeladvies</option>
<option value="vrije_notitie">Vrije notitie</option>
</select>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<div className="flex gap-2 justify-end">
<Button variant="outline" onClick={onClose} disabled={isSaving}>
Annuleren
</Button>
<Button
type="button"
onClick={handleAnalyze}
disabled={contentInvalid || isAnalyzing || isSaving}
>
{isAnalyzing ? 'Analyseren…' : 'Analyseer met AI'}
</Button>
<Button
type="button"
onClick={handleSave}
disabled={contentInvalid || isSaving}
>
{isSaving ? 'Opslaan…' : 'Opslaan'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -2,12 +2,15 @@
import { useEffect, useRef, useState, useCallback } from 'react'; import { useEffect, useRef, useState, useCallback } from 'react';
import { Loader2, Mic, Square } from 'lucide-react'; import { Loader2, Mic, Square } from 'lucide-react';
import { cn } from '@/lib/utils';
interface SpeechRecorderProps { export interface SpeechRecorderProps {
onTranscript: (text: string) => void; onTranscript: (text: string) => void;
disabled?: boolean;
className?: string;
} }
export function SpeechRecorder({ onTranscript }: SpeechRecorderProps) { export function SpeechRecorder({ onTranscript, disabled = false, className }: SpeechRecorderProps) {
const mediaRecorderRef = useRef<MediaRecorder | null>(null); const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]); const chunksRef = useRef<Blob[]>([]);
const [isRecording, setIsRecording] = useState(false); const [isRecording, setIsRecording] = useState(false);
@@ -64,6 +67,8 @@ export function SpeechRecorder({ onTranscript }: SpeechRecorderProps) {
}, [onTranscript]); }, [onTranscript]);
const startRecording = async () => { const startRecording = async () => {
if (disabled || isUploading) return;
try { try {
setError(null); setError(null);
setPermissionDenied(false); setPermissionDenied(false);
@@ -97,7 +102,7 @@ export function SpeechRecorder({ onTranscript }: SpeechRecorderProps) {
const isBusy = isRecording || isUploading; const isBusy = isRecording || isUploading;
return ( return (
<div className="rounded-lg border border-slate-200 p-4 bg-white space-y-2"> <div className={cn('rounded-lg border border-slate-200 p-4 bg-white space-y-2', className)}>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm font-semibold text-slate-900">Spraak-naar-tekst</p> <p className="text-sm font-semibold text-slate-900">Spraak-naar-tekst</p>
@@ -108,10 +113,18 @@ export function SpeechRecorder({ onTranscript }: SpeechRecorderProps) {
<button <button
type="button" type="button"
onClick={isRecording ? stopRecording : startRecording} onClick={isRecording ? stopRecording : startRecording}
disabled={isUploading} disabled={isUploading || disabled}
className="inline-flex items-center gap-2 rounded-md border border-slate-300 px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-50 disabled:opacity-60" className="inline-flex items-center gap-2 rounded-md border border-slate-300 px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-50 disabled:opacity-60"
> >
{isRecording ? <><Square className="h-3 w-3 text-red-600" /> Stop</> : <><Mic className="h-3 w-3 text-teal-600" /> Opnemen</>} {isRecording ? (
<>
<Square className="h-3 w-3 text-red-600" /> Stop
</>
) : (
<>
<Mic className="h-3 w-3 text-teal-600" /> Opnemen
</>
)}
</button> </button>
</div> </div>
{isUploading && ( {isUploading && (
@@ -119,12 +132,8 @@ export function SpeechRecorder({ onTranscript }: SpeechRecorderProps) {
<Loader2 className="h-3 w-3 animate-spin" /> Transcriptie bezig <Loader2 className="h-3 w-3 animate-spin" /> Transcriptie bezig
</p> </p>
)} )}
{permissionDenied && ( {permissionDenied && <p className="text-xs text-red-600">Microfoontoegang nodig om op te nemen.</p>}
<p className="text-xs text-red-600">Microfoontoegang nodig om op te nemen.</p> {error && !permissionDenied && <p className="text-xs text-red-600">{error}</p>}
)}
{error && !permissionDenied && (
<p className="text-xs text-red-600">{error}</p>
)}
</div> </div>
); );
} }

122
components/ui/dialog.tsx Normal file
View File

@@ -0,0 +1,122 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -0,0 +1,201 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

129
components/ui/toast.tsx Normal file
View File

@@ -0,0 +1,129 @@
"use client"
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}

35
components/ui/toaster.tsx Normal file
View File

@@ -0,0 +1,35 @@
"use client"
import { useToast } from "@/hooks/use-toast"
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast"
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}

View File

@@ -84,20 +84,28 @@
"order": 5, "order": 5,
"status": "planned" "status": "planned"
}, },
{
"slug": "screening-system",
"title": "Screening Systeem",
"group": "features",
"description": "Screening proces voor nieuwe cliënten met hulpvraag en besluitvorming",
"order": 6,
"status": "active"
},
{ {
"slug": "intake-system", "slug": "intake-system",
"title": "Intake Systeem", "title": "Intake Systeem",
"group": "features", "group": "features",
"description": "Intake gesprekken en AI-analyse", "description": "Intake gesprekken en gestructureerde intake-workflow",
"order": 6, "order": 7,
"status": "planned" "status": "active"
}, },
{ {
"slug": "treatment-planning", "slug": "treatment-planning",
"title": "Behandelplanning", "title": "Behandelplanning",
"group": "features", "group": "features",
"description": "SMART doelen en voortgang monitoring", "description": "SMART doelen en voortgang monitoring",
"order": 7, "order": 8,
"status": "planned" "status": "planned"
}, },
{ {
@@ -105,14 +113,14 @@
"title": "AI Integrations", "title": "AI Integrations",
"group": "features", "group": "features",
"description": "AI-gestuurde workflows", "description": "AI-gestuurde workflows",
"order": 8 "order": 9
}, },
{ {
"slug": "verpleegkundige-overdracht", "slug": "verpleegkundige-overdracht",
"title": "Verpleegkundige Overdracht", "title": "Verpleegkundige Overdracht",
"group": "features", "group": "features",
"description": "Efficiënte overdracht voor GGZ verpleegkundigen met ROM-metingen", "description": "Efficiënte overdracht voor GGZ verpleegkundigen met ROM-metingen",
"order": 9, "order": 10,
"status": "planned" "status": "planned"
}, },
{ {
@@ -120,7 +128,7 @@
"title": "Spraakgestuurd Rapporteren", "title": "Spraakgestuurd Rapporteren",
"group": "features", "group": "features",
"description": "AI-gedreven spraakopname voor dictaat en metingen extractie", "description": "AI-gedreven spraakopname voor dictaat en metingen extractie",
"order": 10, "order": 11,
"status": "planned" "status": "planned"
}, },
{ {

View File

@@ -3,63 +3,201 @@ title: "Intake Systeem"
category: "intake-system" category: "intake-system"
group: "features" group: "features"
version: "1.0.0" version: "1.0.0"
releaseDate: "TBD" releaseDate: "2025-11-23"
status: "planned" status: "completed"
description: "Intake gesprekken registreren, notities maken en AI-gestuurde analyse voor diagnose ondersteuning" description: "Intake gesprekken registreren, notities maken en gestructureerde intake-workflow voor GGZ-behandelaars"
--- ---
## Overview ## Overzicht
Het intake systeem vormt de basis voor cliëntbegeleiding in het Mini-ECD prototype. Behandelaars kunnen hier gesprekken documenteren en automatisch laten analyseren door AI. Het intake systeem biedt een complete workflow voor het uitvoeren en documenteren van intakes in de GGZ. Na een succesvolle screening wordt een intake aangemaakt waarbij alle aspecten van de hulpvraag systematisch worden gedocumenteerd.
**Kernfunctionaliteit:** **Kernfunctionaliteit:**
- Intake gesprekken registreren - Nieuwe intakes aanmaken per afdeling
- Gestructureerde notities maken - Overzicht van alle intakes van een cliënt
- AI-gestuurde samenvatting - Gestructureerde intake met meerdere onderdelen
- Probleemprofiel analyse - Contactmomenten registreren
- Koppeling naar diagnose - Anamnese vastleggen
- Risicotaxatie uitvoeren
- Diagnose stellen
- Behandeladvies formuleren
--- ---
## Binnenkort beschikbaar ## Intake Aanmaken
Deze documentatie is nog in ontwikkeling. De volgende onderwerpen worden toegevoegd: ### Nieuwe Intake Starten
**Intake Formulier:** 1. Open het cliëntdossier
- Gespreksnotities editor 2. Navigeer naar het tabblad "Intakes"
- Gestructureerde vragen 3. Klik op **"Nieuwe Intake"**
- Type intake (eerste gesprek, telefonisch, follow-up) 4. Vul het formulier in:
- Tijdregistratie - **Titel**: Korte omschrijving (bijv. "Eerste intake ambulant")
- **Afdeling**: Selecteer de behandelafdeling (Volwassenen, Jeugd, Forensisch, etc.)
- **Status**: Lopend / Afgerond / Geannuleerd
- **Startdatum**: Datum van eerste contact
- **Notities**: Extra context of bijzonderheden
**AI Samenvatting:** 5. Klik op **"Intake Aanmaken"**
- Automatische analyse van notities
- Kernpunten extractie
- Probleemcategorieën identificatie
- Risico-inschatting
**Intake Overzicht:** De intake wordt aangemaakt en u wordt doorgeleid naar de intake detail pagina.
- Chronologische geschiedenis
- Detail weergave per intake
- Slide-in panel interface
- Bewerken en verwijderen
**Probleemprofiel:** ---
- DSM-light categorisatie
- Ernst indicatie ## Intake Overzicht
- Multi-problematiek detectie
- Overgang naar diagnose Het intake overzicht toont alle intakes van een cliënt in chronologische volgorde.
**Weergegeven informatie:**
- Titel van de intake
- Afdeling waar de intake plaatsvindt
- Status (lopend, afgerond, geannuleerd)
- Startdatum
**Acties:**
- Klik op een intake om de details te bekijken
- Bekijk en bewerk alle onderdelen via de tabbladen
---
## Intake Onderdelen
Elke intake bestaat uit meerdere onderdelen die via tabbladen toegankelijk zijn:
### 1. Algemeen
Toont de basisinformatie van de intake:
- Titel, afdeling, status, startdatum
- Algemene notities bij de intake
### 2. Contactmomenten
Registreer alle contactmomenten tijdens de intake:
- **Datum en tijd** van het contact
- **Type contact**: Gesprek, Telefonisch, E-mail, Overig
- **Duur** in minuten
- **Notities** over het contact
**Handelingen:**
- Voeg een nieuw contactmoment toe
- Bekijk chronologisch overzicht
- Verwijder contactmomenten indien nodig
### 3. Kindcheck
Specifiek voor jeugd-intakes: vastleggen van kindcheck informatie conform GGZ-richtlijnen.
### 4. Risicotaxatie
Beoordeel risico's zoals:
- Suïcidaliteit
- Agressie
- Zelfverwaarlozing
- Middelengebruik
- Andere risicofactoren
**Per risico vastleggen:**
- **Datum** van de taxatie
- **Risicotype**
- **Ernst**: Laag, Matig, Hoog, Acuut
- **Bevindingen**: Beschrijving van het risico
- **Maatregelen**: Genomen acties
### 5. Anamnese
Systematisch verzamelen van voorgeschiedenis:
**Types anamnese:**
- Psychiatrische anamnese
- Sociale anamnese
- Medische anamnese
- Familieanamnese
- Ontwikkelingsanamnese
- Overig
**Per anamnese vastleggen:**
- **Datum** van gesprek
- **Type** anamnese
- **Inhoud**: Bevindingen en observaties
- **Notities**: Extra context
### 6. Onderzoeken
Registreer alle uitgevoerde onderzoeken:
- Psychologisch onderzoek
- Neurologisch onderzoek
- Laboratoriumonderzoek
- Beeldvorming
- Overig
**Per onderzoek:**
- **Datum**
- **Type onderzoek**
- **Uitslag**: Bevindingen
- **Interpretatie**: Conclusies
### 7. ROM (Routine Outcome Monitoring)
Vastleggen van ROM-metingen voor behandelevaluatie.
### 8. Diagnose
Stel diagnoses volgens DSM-5 classificatie:
- **Datum** van diagnose
- **Type**: Hoofddiagnose / Nevendiagnose
- **DSM-code** en omschrijving
- **Toelichting**: Onderbouwing van de diagnose
### 9. Behandeladvies
Formuleer het behandeladvies op basis van de intake:
- **Advies**: Welke behandeling wordt geadviseerd
- **Doelen**: Behandeldoelen
- **Afspraken**: Vervolgstappen
---
## Workflow
### Typische Intake Workflow
1. **Intake starten** na screening besluit
2. **Contactmomenten** plannen en vastleggen
3. **Kindcheck** uitvoeren (indien van toepassing)
4. **Risicotaxatie** uitvoeren
5. **Anamnese** afnemen (meerdere types)
6. **Onderzoeken** aanvragen en resultaten vastleggen
7. **ROM-metingen** uitvoeren
8. **Diagnose** stellen
9. **Behandeladvies** formuleren
10. Intake afronden (status wijzigen naar "Afgerond")
---
## Tips voor Gebruik
**Goede praktijk:**
- Vul onderdelen meteen na het gesprek in voor beste nauwkeurigheid
- Gebruik de notitievelden voor context die niet in standaardvelden past
- Documenteer alle contactmomenten voor volledig overzicht
- Voer risicotaxatie regelmatig uit bij wijzigende situaties
- Gebruik duidelijke datums om chronologie te behouden
**Let op:**
- Alle gegevens worden automatisch opgeslagen
- Verwijderde items kunnen niet worden hersteld
- Zorg voor complete documentatie voordat u de intake afrondt
--- ---
## Gerelateerde Documentatie ## Gerelateerde Documentatie
**Interface:** **Workflow:**
- [Interface Design System](/documentatie/interface-design) - UI/UX specificaties voor intake schermen - [Screening Systeem](/documentatie/screening-system) - Voorafgaand aan intake
**Features:** **Interface:**
- Client Management (binnenkort) - [Interface Design System](/documentatie/interface-design) - UI/UX specificaties
- Behandelplanning (binnenkort)
- AI Integrations (binnenkort)
**Basis:** **Basis:**
- [Authentication System](/documentatie/authentication) - Toegangsbeheer - [Authentication System](/documentatie/authentication) - Toegangsbeheer
- [Client Management](/documentatie/client-management) - Cliëntbeheer

View File

@@ -0,0 +1,203 @@
---
title: "Screening Systeem"
category: "screening-system"
group: "features"
version: "1.0.0"
releaseDate: "2025-11-23"
status: "completed"
description: "Screening proces voor nieuwe cliënten: hulpvraag, documenten en besluitvorming"
---
## Overzicht
Het screening systeem is de eerste stap in het cliënttraject. Hier wordt de hulpvraag van een nieuwe cliënt beoordeeld en wordt bepaald of de cliënt geschikt is voor intake bij de instelling.
**Kernfunctionaliteit:**
- Hulpvraag vastleggen
- Documenten uploaden (verwijsbrief, intake formulieren)
- Screeningsbesluit nemen
- Afdeling voor intake bepalen
- Activiteitenlog bijhouden
---
## Screening Pagina
De screening pagina bestaat uit vier hoofdonderdelen:
### 1. Hulpvraag
Hier legt u de zorgvraag van de cliënt vast.
**Hoe te gebruiken:**
1. Klik in het tekstveld "Hulpvraag"
2. Beschrijf de hulpvraag van de cliënt in eigen woorden
3. Neem belangrijke context en achtergrond op
4. Klik op **"Opslaan"**
**Tips:**
- Wees specifiek over de aard van de hulpvraag
- Vermeld urgentie indien van toepassing
- Noteer bijzondere omstandigheden
### 2. Screeningsbesluit
Het screeningsbesluit is alleen zichtbaar voor psychologen en behandelaars.
**Opties:**
- **Geschikt voor intake**: Cliënt wordt doorgestuurd voor intake
- **Niet geschikt / doorverwijzen**: Cliënt wordt niet aangenomen
**Stappen voor "Geschikt voor intake":**
1. Selecteer **"Geschikt voor intake"**
2. Kies de **afdeling** waar de intake moet plaatsvinden:
- Volwassenen
- Jeugd (< 18 jaar)
- Forensisch
- Verslaving
- Ouderen (65+)
- FACT
3. Voeg optioneel **notities** toe bij het besluit
4. Klik op **"Opslaan besluit"**
**Stappen voor "Niet geschikt":**
1. Selecteer **"Niet geschikt / doorverwijzen"**
2. Voeg **notities** toe met reden of alternatief
3. Klik op **"Opslaan besluit"**
**Waarschuwing:**
Als er nog geen verwijsbrief is geüpload, wordt een waarschuwing getoond. Het is sterk aan te raden om een verwijsbrief toe te voegen voordat u een definitief besluit neemt.
### 3. Documenten
Upload en beheer documenten voor de screening.
**Documenttypen:**
- Verwijsbrief (vereist voor volledig dossier)
- Intake formulier
- Medische informatie
- Andere relevante documenten
**Document uploaden:**
1. Klik op **"Document toevoegen"**
2. Selecteer het **documenttype**
3. Voeg optionele **notities** toe
4. Upload het bestand
5. Klik op **"Opslaan"**
**Documenten bekijken:**
- Klik op een document in de lijst om te openen
- Zie documenttype, datum en notities
- Download indien nodig
**Documenten verwijderen:**
- Klik op de verwijderknop bij het document
- Bevestig de verwijdering
### 4. Activiteitenlog
De activiteitenlog toont automatisch alle acties die zijn uitgevoerd tijdens de screening.
**Vastgelegde activiteiten:**
- Hulpvraag toegevoegd/gewijzigd
- Document geüpload
- Screeningsbesluit genomen
- Afdeling toegewezen
**Informatie per activiteit:**
- Type activiteit
- Tijdstip
- Details van de actie
De activiteitenlog wordt automatisch bijgewerkt en hoeft niet handmatig te worden beheerd.
---
## Workflow
### Standaard Screening Workflow
1. **Cliënt aanmaken** in het systeem
2. Naar **Screening** tabblad navigeren
3. **Hulpvraag** vastleggen op basis van intake gesprek/formulier
4. **Verwijsbrief** uploaden (indien beschikbaar)
5. Eventueel aanvullende **documenten** uploaden
6. **Screeningsbesluit** nemen:
- Beoordeel of cliënt geschikt is
- Selecteer afdeling bij goedkeuring
- Documenteer reden bij afwijzing
7. Bij goedkeuring: **Intake aanmaken** (zie [Intake Systeem](/documentatie/intake-system))
---
## Toegangsrechten
**Hulpvraag:**
- Zichtbaar voor: Alle behandelaars
- Bewerken: Alle behandelaars
**Screeningsbesluit:**
- Zichtbaar voor: Alleen psychologen en senior behandelaars
- Bewerken: Alleen psychologen en senior behandelaars
**Documenten:**
- Zichtbaar voor: Alle behandelaars met toegang tot cliënt
- Uploaden: Alle behandelaars
- Verwijderen: Alleen uploader en senior behandelaars
**Activiteitenlog:**
- Zichtbaar voor: Alle behandelaars
- Bewerken: Niet mogelijk (automatisch)
---
## Tips voor Gebruik
**Goede praktijk:**
- Upload altijd een verwijsbrief voordat u een screeningsbesluit neemt
- Wees helder en beknopt in de hulpvraag omschrijving
- Documenteer de reden bij afwijzing voor toekomstige referentie
- Controleer de activiteitenlog voor volledigheid van het proces
**Let op:**
- Screeningsbesluiten zijn definitief en vormen basis voor vervolgtraject
- Upload geen documenten met persoonlijke gegevens van derden
- Alle acties worden automatisch gelogd in de activiteitenlog
**Veelgemaakte fouten:**
- Vergeten verwijsbrief te uploaden
- Geen afdeling selecteren bij goedkeuring
- Onvolledige hulpvraag beschrijving
---
## Na Screening
### Bij goedkeuring (Geschikt voor intake):
1. Screeningsbesluit is vastgelegd met toegewezen afdeling
2. Ga naar **Intakes** tabblad
3. Klik op **"Nieuwe Intake"**
4. Selecteer de juiste afdeling (zoals bepaald bij screening)
5. Volg de intake workflow (zie [Intake Systeem](/documentatie/intake-system))
### Bij afwijzing (Niet geschikt):
1. Screeningsbesluit is vastgelegd met reden
2. Informeer cliënt over besluit en alternatieve zorgopties
3. Dossier kan worden gearchiveerd
4. Documentatie blijft beschikbaar voor eventuele vragen
---
## Gerelateerde Documentatie
**Workflow:**
- [Intake Systeem](/documentatie/intake-system) - Vervolg na goedkeuring
- [Client Management](/documentatie/client-management) - Cliëntbeheer
**Interface:**
- [Interface Design System](/documentatie/interface-design) - UI/UX specificaties
**Basis:**
- [Authentication System](/documentatie/authentication) - Toegangsbeheer

View File

@@ -1,45 +0,0 @@
## 2025-11-23 — Clients → Patients datafix + seeds (Colin)
- Clients die na de hoofdrelease nog in `clients` stonden opgespoord met `clients LEFT JOIN patients` sanity check (Supabase SQL editor)
- Idempotente blok uit `20241121_migrate_legacy_to_fhir.sql` opnieuw gedraaid zodat alle ontbrekende clients nu als patients bestaan
- Verification DO-block uitgevoerd zodat de teller op 0 missing clients staat en de migratie gelogd
- Default organisatie opnieuw gezaaid met `pnpm tsx scripts/seed-organization.ts` (env geladen via `.env.local`)
## 2026-02-XX — Screening Intake Fase 1 & 2 (Colin)
- TipTap placeholder hints toegevoegd + styled zodat behandeladvies-sectie guidance toont
- Metadata-sectie, afrondingsworkflow en behandelaarselecties bouwden de behandeladvies-tab af
- Intake-tabs (contact, kindcheck, risico, anamnese, onderzoeken/ROM, diagnose, behandeladvies) compleet met Supabase CRUD
- Status dropdowns gecorrigeerd (bezig/afgerond) en dokument flows afgerond
- Supabase migratiestappen wachten nog op einde maintenance (fase 0 runbook ligt klaar)
## 2025-11-23 — Universele Rapportage backend + modal (Colin)
- API: `/api/reports` (GET/POST) + `/api/reports/[reportId]` (GET/PATCH/DELETE) + `/api/reports/classify` staan, met Zod-validatie en soft delete
- Supabase: `reports` tabel + migratie + RLS policies uitgerold, types toegevoegd in `lib/supabase/database.types.ts`
- Server actions: `app/epd/patients/[id]/rapportage/actions.ts` + gedeelde `lib/server/api-client.ts` houden fetch logic DRY
- UI: shadcn-dialog gebaseerd Rapportage Modal met textarea, speech recorder, AI-analyse en save flow (E2.S1)
## 2025-11-23 — ClientSidebar duplicate cleanup (Colin)
- **Bug fix**: Dubbele sidebar (EPDSidebar + ClientSidebar) in patient detail routes verwijderd
- ClientSidebar (`app/epd/patients/[id]/components/client-sidebar.tsx`) blijkt 100% duplicate van EPDSidebar Level 2 navigatie
- EPDSidebar is al context-aware: detecteert patient routes en switcht automatisch tussen Level 1 (behandelaar) en Level 2 (patient) navigatie
- Verwijderd: ClientSidebar component (117 regels) + EPDLayoutClient wrapper (conditionele sidebar hiding)
- Vereenvoudigd: patient detail layout gebruikt nu alleen PatientLayoutClient zonder eigen sidebar rendering
- Resultaat: Eén sidebar component die automatisch switcht, -117 regels duplicate code, DRY principle hersteld
## 2025-11-23 — EPDHeader patient selector cleanup (Colin)
- **Bug fix**: Dubbele patient naam in UI (EPDHeader top bar + ClientHeader) verwijderd
- Design conflict ontstaan tijdens parallel development: EPDHeader (Epic 1) en ClientHeader (Epic 2) toonden beide patient naam
- EPDHeader's patient selector (center section met naam, ID, geboortedatum) was redundant na toevoegen ClientHeader
- Verwijderd: Patient fetch logic (useState, useEffect), patient selector UI, onnodige imports uit EPDHeader
- Component ownership verduidelijkt: EPDHeader = algemene navigatie (logo, search), ClientHeader = patient context (naam, status, acties)
- Resultaat: EPDHeader vereenvoudigd van 91 naar 34 regels (-57 regels), duidelijke separation of concerns
## 2025-11-23 — Reports created_by foreign key fix (Colin)
- **Bug fix**: Foreign key constraint violation bij opslaan rapportages
- Probleem: `reports.created_by` had FK constraint naar `practitioners.id`, maar code sloeg `auth.uid()` op
- Root cause: Practitioners tabel heeft `user_id` kolom, maar alle demo records hebben `user_id: null` (geen link naar auth users)
- Error: `insert or update on table "reports" violates foreign key constraint "reports_created_by_fkey"`
- Oplossing (prototype): Foreign key constraints verwijderd voor `created_by` en `updated_by` kolommen
- Migratie: `20251123_fix_reports_created_by_constraint.sql` toegepast via Supabase MCP
- Resultaat: Rapportages kunnen nu opgeslagen worden met auth user ID zonder FK constraint
- **Note voor productie**: In productie zou je practitioners.user_id vullen en FK constraints herstellen voor data integriteit

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

View File

@@ -0,0 +1,137 @@
# 🧩 Functioneel Ontwerp (FO) Universele Rapportage Split View MVP
**Projectnaam:** Mini-EPD Universele Rapportage Split View
**Versie:** v0.1 (concept MVP)
**Datum:** 06-03-2025
**Auteur:** Codex Agent
---
## 1. Doel en relatie met het PRD
🎯 **Doel van dit document:**
Beschrijving van de functionele werking voor een MVP waarin behandelaren tegelijk een rapportage kunnen schrijven en eerdere notities kunnen raadplegen, passend binnen het bestaande PRD voor Universele Rapportage.
📘 **Toelichting aan de lezer:**
Het FO focust op de split-view workflow (timeline + composer). Technische keuzes (server actions, Supabase) staan elders. Gebruik dit document om scope-afbakening voor MVP (Week3) af te stemmen.
---
## 2. Overzicht van de belangrijkste onderdelen
1. **Rapportage Header & Navigatie** knop "Nieuwe rapportage" scrollt naar composer en linkt vanaf andere tabs.
2. **KPI Tiles** tonen kernstatistieken (totaal, AI, laatste activiteit).
3. **Filterpaneel** zoekveld, typefilter, (nog toe te voegen) auteur/datum-filter.
4. **Timeline Lijst** scrollbare lijst met kaartjes, selectie, soft delete.
5. **Composer Paneel** tekstarea, spraakinput, AI-classificatie, referenties.
6. **Mobiele Drawer** responsive variant waarin timeline↔composer gewisseld worden. *(Nog te bouwen)*
---
## 3. Userstories
| ID | Rol | Doel / Actie | Waarde | Prioriteit |
|----|-----|--------------|--------|------------|
| UR-01 | Behandelaar | Terwijl ik schrijf wil ik vorige rapportages kunnen inzien | Context houden, fouten voorkomen | Hoog |
| UR-02 | Behandelaar | Ik wil via filters snel de juiste notitie van collega vinden | Tijdsbesparing | Hoog |
| UR-03 | Behandelaar | Ik wil AI laten bepalen welk type rapport ik schrijf | Consistentie, minder fouten | Hoog |
| UR-04 | Behandelaar | Ik wil een geselecteerde notitie als quote aan mijn verslag toevoegen | Bronverwijzing, samenwerking | Middel |
| UR-05 | Behandelaar | Op mobiel wil ik vloeiend wisselen tussen lezen en schrijven | Onderweg kunnen rapporteren | Middel |
| UR-06 | PO | Ik wil weten hoeveel AI-notities er zijn per client | KPI/usage | Laag |
---
## 4. Functionele werking per onderdeel
### 4.1 Rapportage Header & Navigatie
* Header toont cliëntnaam, status, laatste wijziging; knop "Nieuwe rapportage".
* Knop logica: staat gebruiker al op `/rapportage`, scroll dan naar composer (`#rapportage-composer` focus). Zo niet, navigeer naar rapportage en append hash zodat browser direct scrolt.
* ID van de composer moet focusbaar zijn (`tabIndex=-1` zodat `focus()` werkt).
### 4.2 KPI Tiles
* Toont 3 cards (totaal rapportages, AI-geclassificeerde rapportages, laatste activiteit).
* Data wordt client-side afgeleid uit `initialReports`.
* Wanneer nieuwe rapportage wordt aangemaakt of verwijderd worden tiles realtime herberekend.
* Lege état: tekst "Nog geen rapportages" + `—` voor laatste activiteit.
### 4.3 Filterpaneel
* Zoekveld filtert op tekst in body of AI reason.
* Type-filter (dropdown) toont alle rapporttypes, standaard "Alle".
* MVP-toevoegingen:
- Auteur-filter (dropdown met lijst van `created_by`).
- Datumfilter (van/tot) met quick-pills (Vandaag, 7d, 30d).
- Resetknop.
### 4.4 Timeline Lijst
* Kaartjes tonen type-badge, timestamp, maker, AI-confidence en contentpreview.
* Klik of Enter/Space selecteert item en highlight border.
* Delete-knop voert soft delete uit via server action, toont toast en verwijdert item lokaal.
* MVP-toevoegingen:
- Inline "Expand" om volledige tekst te lezen zonder composer te verlaten.
- Paginatie of virtual scroll voor >50 items.
- Indicator welke notities AI-gegenereerd zijn (badge).
- Autorisatie-check (alleen eigen dossiers, reeds door Supabase RLS, maar lege states voor forbidden).
### 4.5 Composer Paneel
* Tekstveld (205000 chars), spraakrecorder (Deepgram), AI-classificatie, type dropdown.
* Bottom bar toont status: "Concept wordt lokaal bijgehouden".
* Opslaan: server action `createReport` → toast success, reset state, highlight nieuwe entry.
* MVP-toevoegingen:
- Draft/autosave naar localStorage (per client) + timestamp, warning bij weg navigeren.
- Undo/redo per sessie.
- Attachments (placeholder).
- Loading skeleton zodat composer niet flasht bij SSR.
### 4.6 Referentie toevoegen
* Wanneer timeline-entry geselecteerd is, toont composer een "Geselecteerde rapportage" kaart.
* "Voeg referentie toe" voegt quote block in met snippet + meta.
* MVP: markeer binnen editor dat het een referentie is (bijv. blockquote-styling) en zorg dat quote 1-op-1 linkt terug naar bron (ID link).
* Bij wisselen van selectie mag composer vragen of huidige quote moet worden vervangen.
### 4.7 Mobiele Drawer (nog bouwen)
* Tabbed header `[Timeline] [Rapportage+]`.
* Timeline tab = stack van kaarten; verkeer identiek.
* Rapportage tab opent composer in drawer (~75% hoogte) met drag handle en "Terug".
* Autosave badge en AI-knoppen bewegen mee.
* Sticky CTA in footer.
---
## 5. UI-overzicht (visuele structuur)
```
┌───────────────────────────────────────────────┐
│ Topbalk: cliëntnaam, acties, zoeken │
├───────────────┬──────────────────────────────┤
│ Filters + │ Composer │
│ Timeline (L) │ - Header "Nieuwe ..." │
│ - Search │ - Textarea + speech │
│ - Type pills │ - AI badge + dropdown │
│ - Timeline │ - Referentie card │
│ │ - Sticky buttons │
├───────────────┴──────────────────────────────┤
│ Footer/Toasts │ Autosave msgs │
└───────────────────────────────────────────────┘
```
Mobiel: tabs bovenaan, composer als drawer.
---
## 6. Interacties met AI
| Locatie | AI-actie | Trigger | Output |
|---------|----------|---------|--------|
| Composer | Classificatie | Knop "Analyseer met AI" | JSON `{ type, confidence, reasoning }`, toont in badge + dropdown default |
| Composer | Typekeuze | User accepteert AI-type | Dropdown wordt vooringevuld, user kan overschrijven |
| (Toekomst) Timeline filter | AI context chips | Klik op AI badge in kaart | Filtert timeline op dat type |
---
## 7. Gebruikersrollen en rechten
| Rol | Toegang | Beperkingen |
|-----|---------|-------------|
| Behandelaar | Rapportage workspace voor eigen cliënten | Alleen RLS-toegelaten dossiers, soft delete alleen eigen rapportages |
| Supervisor (optioneel) | Timeline lezen | Geen composer, geen delete |
---
## 8. Bijlagen & Referenties
- PRD Universele Rapportage v1.0
- TO Universele Rapportage v1.0
- Bouwplan Universele Rapportage v1.0
- Rapportage Split View ontwerpnotities (docs/specs/screening-intake/rapportage-split-view-design.md)

View File

@@ -0,0 +1,935 @@
# 🧩 Functioneel Ontwerp (FO) — Universele Rapportage
**Projectnaam:** Mini-ECD - Universele Rapportage Functie
**Versie:** v1.0 (Week 2 MVP)
**Datum:** 23-11-2024
**Auteur:** Colin van der Heijden (AI Speedrun)
---
## 1. Doel en relatie met het PRD
🎯 **Doel van dit document:**
Het Functioneel Ontwerp (FO) beschrijft **hoe** de Universele Rapportage functie werkt — dus wat de gebruiker ziet, doet en ervaart. Waar het PRD uitlegt *wat en waarom*, laat het FO zien *hoe dit in de praktijk werkt*.
📘 **Toelichting aan de lezer:**
De Universele Rapportage functie is een fundamenteel andere benadering van verslaglegging in EPD-systemen. In plaats van "zoek het juiste formulier → vul velden in → save", wordt het: "rapporteer wat je wilt vastleggen → AI begrijpt de context → klaar".
**Relatie met PRD v1.2:**
- Core thesis: "Software on Demand" — AI-first workflow design
- User Story: "Als behandelaar wil ik snel rapporteren zonder te zoeken naar formulieren"
- Succes criterium: "Van idee naar opgeslagen verslag in <30 seconden"
**Context binnen AI Speedrun:**
- **Week 2 MVP:** Text input + behandeladvies classificatie + tijdlijn
- **Week 3:** Voice input (Deepgram) + meerdere rapportage types
- **Week 4:** Structured data extraction + dashboard metrics
**Waarom dit anders is dan traditionele EPD's:**
- Traditioneel: 47 verschillende formulieren, elk met specifieke velden
- AI Speedrun: 1 universele functie, AI routeert automatisch naar juiste context
---
## 2. Overzicht van de belangrijkste onderdelen
🎯 **Doel:** kort overzicht van de modules en componenten binnen deze feature.
**Nieuwe componenten (Week 2):**
1. **Floating Action Button** — Universele toegang tot rapportage functie
2. **Rapportage Modal** — Input interface voor verslaglegging
3. **AI Classificatie Engine** — Detecteert type rapportage
4. **Tijdlijn Component** — Chronologische weergave alle rapportages
5. **Context Routing** — Automatisch plaatsen in juiste sectie
**Integratie met bestaande structuur:**
- Client Dashboard — tijdlijn widget + snelkoppeling
- Behandelplan tab — automatisch gevulde behandeladviezen
- Database — nieuwe `reports` tabel
---
## 3. User Stories (Week 2 MVP scope)
🎯 **Doel:** beschrijven wat gebruikers moeten kunnen doen, vanuit hun perspectief.
**User Story Template:**
> Als [rol/gebruiker] wil ik [doel of actie] zodat [reden/waarde].
**MVP Stories:**
| ID | Rol | Doel / Actie | Verwachte waarde | Prioriteit |
|----|------|---------------|------------------|-------------|
| US-20 | Behandelaar | Snel rapporteren vanaf elke plek in dossier | Geen zoeken naar juiste formulier | Hoog |
| US-21 | Behandelaar | AI laten detecteren wat voor type rapportage het is | Geen handmatig categoriseren | Hoog |
| US-22 | Behandelaar | Type handmatig kunnen wijzigen als AI verkeerd zit | Controle over eindresultaat | Hoog |
| US-23 | Behandelaar | Chronologische tijdlijn zien van alle rapportages | Overzicht van historiek | Hoog |
| US-24 | Behandelaar | Behandeladviezen automatisch in juiste sectie zien | Geen dubbel werk | Hoog |
| US-25 | Demo-bezoeker | Concept zien werken in <30 seconden | Begrijpen waarom dit beter is | Kritiek |
| US-26 | Product Owner | Uitbreidbaarheid naar meer types zien | Vertrouwen in schaalbaarheid | Middel |
**Post-MVP Stories (Week 3+):**
| ID | Rol | Doel / Actie | Verwachte waarde | Prioriteit |
|----|------|---------------|------------------|-------------|
| US-27 | Behandelaar | Voice input gebruiken tijdens gesprek | Handen vrij voor aantekeningen | Middel |
| US-28 | Behandelaar | Filteren op rapportage type | Snel specifieke verslagen vinden | Middel |
| US-29 | Behandelaar | Keyboard shortcut gebruiken | Nog sneller toegang | Laag |
| US-30 | Behandelaar | Structured data automatisch extraheren | Geen handmatig invullen velden | Middel |
---
## 4. Functionele werking per onderdeel
🎯 **Doel:** per hoofdonderdeel beschrijven wat de gebruiker kan doen en wat het systeem doet.
### 4.1 Floating Action Button (FAB)
**Doel:**
Universele, altijd beschikbare toegang tot rapportage functie vanuit elk scherm binnen het clientdossier.
**Locatie:**
- Rechterboven in clientdossier interface
- Persistent (blijft zichtbaar bij scrollen)
- Alleen zichtbaar binnen clientdossier context
**Visueel:**
- Icoon: 🎤 (microfoon voor toekomstige voice functie)
- Label: "Rapporteer"
- Styling: Primary brand color, elevated (shadow)
- Hover state: Subtle scale + glow effect
**Gedrag:**
- Klik → opent Rapportage Modal
- Disabled state: Als geen client geselecteerd
- Tooltip (hover): "Snel rapporteren (Cmd+Shift+R)" *[Week 3]*
**States:**
```
Normal: [🎤 Rapporteer]
Hover: [🎤 Rapporteer] (110% scale)
Loading: [⏳ Bezig...]
Disabled: [🎤 Rapporteer] (50% opacity, geen pointer)
```
---
### 4.2 Rapportage Modal (Week 2 MVP)
**Doel:**
Centrale interface voor het invoeren van rapportages met AI-ondersteuning.
**Trigger:**
- Klik op FAB
- Keyboard shortcut (Week 3): `Cmd+Shift+R` / `Ctrl+Shift+R`
**Modal structuur:**
**Header:**
```
┌─────────────────────────────────────────┐
│ 🎤 Nieuwe rapportage voor Emma de Vries│
│ [X] │
└─────────────────────────────────────────┘
```
**Body (lege staat):**
```
┌─────────────────────────────────────────┐
│ │
│ ┌───────────────────────────────────┐ │
│ │ │ │
│ │ Beschrijf wat je wilt │ │
│ │ vastleggen... │ │
│ │ │ │
│ │ (minimaal 20 karakters) │ │
│ │ │ │
│ └───────────────────────────────────┘ │
│ │
│ 💡 Tip: Schrijf gewoon wat je wilt │
│ vastleggen. AI herkent automatisch │
│ of het een behandeladvies is. │
│ │
├─────────────────────────────────────────┤
│ [Analyseer met AI] │
└─────────────────────────────────────────┘
```
**Body (na invoer tekst):**
```
┌─────────────────────────────────────────┐
│ ┌───────────────────────────────────┐ │
│ │ Op basis van het intakegesprek │ │
│ │ advies ik een CGT traject van │ │
│ │ 12 sessies met focus op... │ │
│ │ ▼ │ │
│ └───────────────────────────────────┘ │
│ │
│ Characters: 156 / 5000 │
│ │
├─────────────────────────────────────────┤
│ [Annuleren] [Analyseer] │
└─────────────────────────────────────────┘
```
**Body (AI classificatie):**
```
┌─────────────────────────────────────────┐
│ ┌───────────────────────────────────┐ │
│ │ Op basis van het intakegesprek │ │
│ │ advies ik een CGT traject van │ │
│ │ 12 sessies met focus op... │ │
│ └───────────────────────────────────┘ │
│ │
│ ✓ AI detecteert: 🎯 Behandeladvies │
│ │
│ Type wijzigen: │
│ ┌─────────────────────────────────┐ │
│ │ 🎯 Behandeladvies ▼ │ │
│ └─────────────────────────────────┘ │
│ │
│ └─ 🎯 Behandeladvies │
│ 📝 Vrije notitie │
│ │
├─────────────────────────────────────────┤
│ [Annuleren] [Opslaan] │
└─────────────────────────────────────────┘
```
**Velden:**
- **Text Area**: Multi-line, auto-resize, 5000 character limit
- **Character Counter**: Live update, warning bij >4500 chars
- **Type Dropdown**: Manual override optie (Week 2: 2 types)
- **AI Confidence Badge**: Visual feedback (alleen intern, niet getoond)
**Validation:**
- Minimaal 20 karakters (voorkom "test" spam)
- Maximaal 5000 karakters
- Lege tekst → "Analyseer" knop disabled
- <20 chars → Error: "Te kort voor analyse (min. 20)"
**Button states:**
```
[Analyseer met AI] → Primary, default
[⏳ Analyseren...] → Loading state (1-2 sec)
[✓ Geanalyseerd] → Success state (2 sec)
[Opslaan] → Primary, na classificatie
[💾 Opslaan...] → Loading tijdens save
[✓ Opgeslagen] → Success, auto-close na 1 sec
```
**Edge cases:**
- AI error → Fallback: "Vrije notitie" + melding
- Netwerk timeout → Retry button + melding
- Client context verloren → Error: "Selecteer eerst een cliënt"
---
### 4.3 AI Classificatie (Week 2 MVP)
**Doel:**
Automatisch herkennen of een rapportage een behandeladvies is of een vrije notitie.
**Technische flow:**
```
User input
Validate (>20 chars)
Send to Claude API
Parse JSON response
Show classification + confidence
User confirms/overrides
Save to database
```
**AI Prompt (approximately):**
```typescript
const CLASSIFICATION_PROMPT = `
Je bent een classificatie-assistent voor een GGZ EPD-systeem.
Classificeer de volgende rapportage als één van deze types:
**Behandeladvies:**
- Bevat een concreet behandelplan of voorstel
- Noemt doelen, interventies, aanpak
- Woorden zoals: "advies", "plan", "traject", "sessies", "behandeling"
- Voorbeelden: "Ik stel voor CGT, 12 sessies", "Behandelplan: ..."
**Vrije notitie:**
- Alles wat niet duidelijk een behandeladvies is
- Algemene observaties, opmerkingen, aantekeningen
- Geen concreet plan of voorstel
RAPPORTAGE:
"""
${userInput}
"""
Return ALLEEN dit JSON format, niets anders:
{
"type": "behandeladvies" | "vrije_notitie",
"confidence": 0.0 - 1.0,
"reasoning": "Korte uitleg waarom (optioneel)"
}
`;
```
**Response handling:**
```typescript
type ClassificationResult = {
type: 'behandeladvies' | 'vrije_notitie';
confidence: number; // 0.0 - 1.0
reasoning?: string;
}
// Confidence thresholds
if (confidence >= 0.85) {
// High confidence - auto-suggest
showClassification(result.type);
} else if (confidence >= 0.60) {
// Medium confidence - show with warning
showClassification(result.type, "AI is niet zeker, controleer type");
} else {
// Low confidence - fallback
showClassification('vrije_notitie', "AI kon type niet bepalen");
}
```
**Fallback scenario's:**
- API timeout → Default: "vrije_notitie"
- Parse error → Default: "vrije_notitie"
- Network error → Retry optie + fallback
**Performance target:**
- Response time: <2 seconden (95th percentile)
- Accuracy target: >85% correct classifications
---
### 4.4 Tijdlijn Component
**Doel:**
Chronologische weergave van alle rapportages voor een cliënt, met snelle toegang tot details.
**Locatie:**
- Tab in clientdossier: "Tijdlijn"
- Widget op dashboard (laatste 3 items)
**Structuur:**
**Header:**
```
┌─────────────────────────────────────────┐
│ 📋 Tijdlijn │
│ │
│ Filter: [Alle types ▼] [Zoeken 🔍] │
└─────────────────────────────────────────┘
```
**Tijdlijn items:**
```
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
23 nov 2024 14:30
🎯 Behandeladvies
"Op basis van het intakegesprek advies
ik een CGT traject van 12 sessies met
focus op slaapproblematiek en..."
Door: Dr. van Dam
[Bekijk volledig] [Bewerk] [...]
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
22 nov 2024 10:15
📝 Vrije notitie
"Telefonisch contact met cliënt.
Afspraak verzet naar donderdag..."
Door: Dr. van Dam
[Bekijk volledig] [Bewerk] [...]
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
```
**Item componenten:**
- **Timestamp**: Datum + tijd (relatief: "2 uur geleden")
- **Type badge**: Icoon + label
- **Preview**: Eerste 120 karakters + "..."
- **Metadata**: Auteur (Week 3: + versie nummer)
- **Actions**: Bekijk, Bewerk, Menu (...)
**States:**
```
Normal: Standaard weergave
Hover: Subtle highlight + cursor pointer
Loading: Skeleton placeholders
Empty: "Nog geen rapportages voor deze cliënt"
```
**Interacties:**
- Klik op item → Open detail view (modal of side panel)
- Klik "Bewerk" → Open in rapportage modal (edit mode)
- Menu (...) → Verwijderen (met confirmatie)
**Lazy loading:**
- Toon eerste 10 items
- "Meer laden" knop onderaan
- Infinite scroll optie (Week 3)
---
### 4.5 Context Routing (automatische plaatsing)
**Doel:**
Rapportages automatisch op de juiste plek in het dossier tonen, naast de chronologische tijdlijn.
**Week 2 scope:**
- Behandeladviezen verschijnen automatisch in "Behandelplan" tab
- Vrije notities blijven alleen in tijdlijn
**Behandelplan tab integratie:**
```
┌─────────────────────────────────────────┐
│ 🎯 Behandelplan │
│ │
│ Laatste advies: 23 nov 2024 14:30 │
│ │
│ ┌─────────────────────────────────┐ │
│ │ CGT traject, 12 sessies │ │
│ │ Focus: slaapproblematiek │ │
│ │ │ │
│ │ Doelen: │ │
│ │ - Verbeteren slaapkwaliteit │ │
│ │ - Verminderen ruminatie │ │
│ │ │ │
│ │ [Bewerk] [Nieuwe versie] │ │
│ └─────────────────────────────────┘ │
│ │
│ Eerdere adviezen: │
│ - 20 nov 2024 (v1.0) [Bekijk] │
└─────────────────────────────────────────┘
```
**Routing logica:**
```typescript
function routeReport(report: Report) {
// Week 2: Simple routing
if (report.type === 'behandeladvies') {
// Show in both timeline AND behandelplan tab
saveToTimeline(report);
linkToBehandelplan(report);
} else {
// Only in timeline
saveToTimeline(report);
}
// Week 3+: More types
// 'intake' → Intake tab
// 'voortgang' → Voortgang tab
// 'crisis' → Crisis log + notification
// etc.
}
```
**Versioning (Week 3):**
- Behandeladviezen krijgen versienummer (v1.0, v1.1, v2.0)
- Nieuwe rapportage = nieuwe versie of wijziging bestaande?
- User kan kiezen: "Nieuwe versie" vs "Bestaande bijwerken"
---
## 5. UI-overzicht (visuele structuur)
🎯 **Doel:** eenvoudig inzicht geven in de globale schermopbouw.
**Clientdossier met Universele Rapportage:**
```
┌──────────────────────────────────────────────────────────┐
│ Logo Emma de Vries (34j, F) [Zoeken] [User ▼] │
├──────────────────────────────────────────────────────────┤
│ │
│ ┌────────────┐ ┌─────────────────────────────────┐ │
│ │ Dashboard │ │ │ │
│ │ Intake │ │ Client Dashboard │ │
│ │ Diagnose │ │ │ │
│ │ Behandel │ │ [Laatste activiteit widgets] │ │
│ │ Tijdlijn │ │ │ │
│ │ │ │ │ │
│ │ │ │ │ │
│ │ │ │ │ │
│ └────────────┘ └─────────────────────────────────┘ │
│ │
│ ┌──────────────────┐ │
│ │ 🎤 Rapporteer │ ←FAB │
│ └──────────────────┘ │
└──────────────────────────────────────────────────────────┘
```
**Rapportage Modal (overlay):**
```
┌────────────────────────────────────────┐
│ 🎤 Nieuwe rapportage voor Emma [X]│
├────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────┐ │
│ │ [User input text area] │ │
│ │ │ │
│ └──────────────────────────────────┘ │
│ │
│ ✓ AI detecteert: 🎯 Behandeladvies │
│ │
│ Type: [Behandeladvies ▼] │
│ │
├────────────────────────────────────────┤
│ [Annuleren] [Opslaan] │
└────────────────────────────────────────┘
```
**Tijdlijn tab:**
```
┌──────────────────────────────────────────────────────────┐
│ 📋 Tijdlijn [Alle types ▼] [Zoeken 🔍] │
├──────────────────────────────────────────────────────────┤
│ │
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
│ 23 nov 2024 14:30 │
│ 🎯 Behandeladvies │
│ │
│ "Op basis van intakegesprek advies ik CGT..." │
│ │
│ Door: Dr. van Dam │
│ [Bekijk volledig] [Bewerk] [...] │
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
│ │
│ 22 nov 2024 10:15 │
│ 📝 Vrije notitie │
│ ... │
│ │
└──────────────────────────────────────────────────────────┘
```
---
## 6. Interacties met AI (functionele beschrijving)
🎯 **Doel:** uitleggen waar AI in de flow voorkomt en wat de gebruiker ziet of verwacht.
| Locatie | AI-actie | Trigger | Input | Output | Response tijd |
|---------|----------|---------|-------|--------|---------------|
| Rapportage Modal | Classificatie | Klik "Analyseer" | User text (20-5000 chars) | Type + confidence | <2 sec |
| Behandelplan (Week 3) | Extractie | Auto na save | Behandeladvies text | SMART doelen, interventies | <3 sec |
| Tijdlijn (Week 3) | Samenvatting | Klik "Samenvatten" | Lang verslag | Bullets, highlights | <2 sec |
**Week 2 MVP: Alleen classificatie**
**Prompt structure:**
```typescript
System: "Je bent classificatie-assistent voor GGZ EPD"
User: "Classificeer: [user_text]"
Expected response: { type, confidence, reasoning }
```
**Error handling:**
- Timeout (>5 sec) → Retry once → Fallback to "vrije_notitie"
- Invalid JSON → Parse error → Fallback to "vrije_notitie"
- Rate limit → Queue request → Show "Even geduld..."
**User feedback:**
```
[Analyseren...] ← Loading (spinner)
[✓ Geanalyseerd] ← Success (1 sec)
[⚠️ Opnieuw proberen] ← Error (retry button)
```
---
## 7. Database Schema (Week 2 MVP)
🎯 **Doel:** Gestructureerde opslag van rapportages met uitbreidbaarheid.
**Tabel: `reports`**
```sql
CREATE TABLE reports (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
client_id UUID NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
-- Content
type VARCHAR(50) NOT NULL, -- 'behandeladvies', 'vrije_notitie'
content TEXT NOT NULL, -- Raw user input
-- AI metadata
ai_confidence DECIMAL(3,2), -- 0.00 - 1.00
ai_reasoning TEXT, -- Optional AI explanation
-- Structured data (Week 3+)
structured_data JSONB, -- Extracted entities, dates, etc.
-- Metadata
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID NOT NULL REFERENCES users(id),
updated_at TIMESTAMPTZ,
updated_by UUID REFERENCES users(id),
-- Versioning (Week 3+)
version VARCHAR(10), -- e.g., "v1.0", "v2.1"
parent_report_id UUID REFERENCES reports(id), -- For versioning
-- Audio (Week 3+)
audio_url TEXT, -- Supabase Storage URL
audio_duration_seconds INT,
-- Soft delete
deleted_at TIMESTAMPTZ,
-- Indexes
INDEX idx_reports_client (client_id),
INDEX idx_reports_type (type),
INDEX idx_reports_created (created_at DESC)
);
```
**Type values (Week 2):**
- `behandeladvies`: Treatment plan/advice
- `vrije_notitie`: Free-form note
**Type values (Week 3+):**
- `intake`: Initial intake report
- `voortgang`: Progress report
- `crisis`: Crisis/incident report
- `contact`: Contact moment (call, email)
- `medicatie`: Medication changes
- `mdo`: Multidisciplinary consultation
**Example data:**
```json
{
"id": "abc-123",
"client_id": "client-456",
"type": "behandeladvies",
"content": "Op basis van intakegesprek advies ik CGT traject...",
"ai_confidence": 0.92,
"ai_reasoning": "Bevat woorden: advies, traject, sessies",
"structured_data": null, // Week 3+
"created_at": "2024-11-23T14:30:00Z",
"created_by": "user-789"
}
```
---
## 8. Gebruikersrollen en rechten
🎯 **Doel:** beschrijven welke rollen toegang hebben tot welke onderdelen.
| Rol | Toegang | Acties | Beperkingen |
|-----|---------|--------|-------------|
| **Behandelaar** | Eigen cliënten | Aanmaken, lezen, bewerken, verwijderen rapportages | Alleen eigen rapportages bewerken |
| **Manager** | Alle cliënten (read-only) | Lezen tijdlijnen | Geen wijzigingen |
| **Demo User** | Demo cliënten | Volledige toegang | Fictieve data only |
**Permissions check:**
```typescript
canCreateReport(user, client) {
return user.clients.includes(client.id) || user.role === 'admin';
}
canEditReport(user, report) {
return report.created_by === user.id || user.role === 'admin';
}
canDeleteReport(user, report) {
return report.created_by === user.id || user.role === 'admin';
}
```
---
## 9. Flows & Scenario's
🎯 **Doel:** Concrete gebruiksscenario's uitwerken.
### 9.1 Happy Flow: Behandeladvies rapporteren
**Context:** Behandelaar heeft net intakegesprek gehad met nieuwe cliënt Emma (34j).
**Stappen:**
1. Open clientdossier Emma de Vries
2. Klik floating button "🎤 Rapporteer" (rechtsonder)
3. Modal opent met focus op text area
4. Typ: *"Op basis van het intakegesprek van vandaag advies ik een CGT traject van 12 sessies. Focus op slaapproblematiek en verminderen van ruminatie. Start met psycho-educatie over depressie en slaaphygiëne. Evaluatie na 6 sessies. Bij onvoldoende verbetering overleg met psychiater voor eventuele medicatie."*
5. Klik "Analyseer met AI"
6. Spinner 1-2 seconden
7. AI toont: "✓ AI detecteert: 🎯 Behandeladvies (92% zeker)"
8. Dropdown toont: "🎯 Behandeladvies" (pre-selected)
9. Klik "Opslaan"
10. Success melding: "✓ Opgeslagen"
11. Modal sluit automatisch na 1 sec
12. Rapportage verschijnt:
- In tijdlijn (bovenaan)
- In "Behandelplan" tab
13. Behandelaar ziet bevestiging toast: "Behandeladvies opgeslagen"
**Tijdsduur:** ~25 seconden (inclusief typen)
---
### 9.2 Alternative Flow: AI classificeert verkeerd
**Context:** Behandelaar schrijft verslag, maar AI herkent verkeerd type.
**Stappen:**
1. Open rapportage modal
2. Typ: *"Kort telefoontje gehad met Emma. Ze kan volgende week niet, afspraak verzet naar donderdag 15:00. Ze klonk opgeruimd."*
3. Klik "Analyseer"
4. AI toont: "✓ AI detecteert: 🎯 Behandeladvies (68% zeker)"
- (AI ziet "afspraak" en "opgeruimd" als mogelijk behandelcontext)
5. **Gebruiker klikt dropdown**
6. Kiest: "📝 Vrije notitie"
7. Klik "Opslaan"
8. Verslag opgeslagen als vrije notitie
9. **Leermoment:** AI wordt niet getraind (out of scope), maar gebruiker had controle
**Tijdsduur:** ~30 seconden
---
### 9.3 Error Flow: Netwerk timeout
**Context:** Slechte internetverbinding tijdens classificatie.
**Stappen:**
1. Typ rapportage
2. Klik "Analyseer"
3. Spinner > 5 seconden
4. Timeout error
5. Melding: "⚠️ AI niet bereikbaar. Probeer opnieuw of kies handmatig een type."
6. Knoppen:
- [Opnieuw proberen] ← Retry
- [Handmatig kiezen] ← Skip AI, direct dropdown
7. Gebruiker kiest "Handmatig kiezen"
8. Dropdown actief, kiest type
9. Opslaan werkt normaal
**Fallback:** Rapportage wordt opgeslagen zonder AI-metadata, maar blijft functioneel.
---
### 9.4 Demo Scenario (LinkedIn video)
**Context:** 30-seconden demo voor LinkedIn post.
**Script:**
```
[Scherm: Client dossier open]
Voiceover: "Traditionele EPD's hebben 47 formulieren."
[Klik Rapporteer button]
Voiceover: "Wij hebben 1 knop."
[Typ snel behandeladvies]
Voiceover: "Je typt wat je denkt."
[Klik Analyseer]
Voiceover: "AI begrijpt wat het is."
[Klik Opslaan]
Voiceover: "En zet het automatisch op de juiste plek."
[Toon tijdlijn + behandelplan tab]
Voiceover: "25 seconden. Klaar."
[Fade to logo]
Text: "AI Speedrun EPD - Software on Demand"
```
**Belangrijke visuele elementen:**
- Smooth animaties
- Duidelijke button clicks
- AI classificatie badge (92%)
- Dual placement (tijdlijn + behandelplan)
---
## 10. Technische constraints & dependencies
🎯 **Doel:** Technische randvoorwaarden en afhankelijkheden.
**API Dependencies:**
- **Claude 3.5 Sonnet** (Anthropic API)
- Endpoint: `/v1/messages`
- Model: `claude-sonnet-4-20250514`
- Rate limit: 50 requests/minute (tier 1)
- Fallback: Queue + retry
**Database:**
- **Supabase PostgreSQL**
- Row Level Security (RLS) enabled
- Real-time subscriptions (Week 3+)
**Frontend:**
- **Next.js 15** (App Router)
- **React 19** (Server Components waar mogelijk)
- **shadcn/ui** components
- **TailwindCSS** styling
**Performance targets:**
- Modal open: <100ms
- AI classification: <2 sec (p95)
- Database save: <500ms
- Timeline render: <200ms (10 items)
**Browser support:**
- Chrome/Edge 90+
- Firefox 88+
- Safari 14+
- Mobile: iOS Safari 14+, Chrome Android
---
## 11. Privacy & Security (AVG compliance)
🎯 **Doel:** Waarborgen van privacy en beveiliging.
**Data opslag:**
- Rapportages bevatten potentieel gevoelige medische informatie
- Encryptie at rest (Supabase default)
- Encryptie in transit (HTTPS/TLS 1.3)
**API communicatie:**
- Claude API: Data wordt NIET gebruikt voor training
- Anthropic heeft SOC 2 Type II certificering
- Data residency: EU servers waar mogelijk
**Toegangscontrole:**
- Row Level Security (RLS) op client_id
- User kan alleen eigen cliënten zien/bewerken
- Audit log voor alle wijzigingen (Week 3+)
**Soft delete:**
- Rapportages krijgen `deleted_at` timestamp
- Niet permanent verwijderd (herstel mogelijk)
- Permanent purge na 30 dagen (Week 4)
**GDPR rechten:**
- Recht op inzage: Tijdlijn export (Week 3+)
- Recht op verwijdering: Soft delete functie
- Recht op dataportabiliteit: JSON export (Week 4)
---
## 12. Testing & Acceptance Criteria
🎯 **Doel:** Definiëren wanneer de feature "klaar" is.
**Functional Tests:**
- [ ] FAB zichtbaar in alle clientdossier paginas
- [ ] Modal opent/sluit correct
- [ ] Text area accepteert 20-5000 karakters
- [ ] Validation errors tonen bij <20 chars
- [ ] AI classificatie werkt in >85% van testcases
- [ ] Dropdown manual override functioneert
- [ ] Save functie slaat op in database
- [ ] Tijdlijn toont nieuwe items direct
- [ ] Behandeladviezen verschijnen in Behandelplan tab
- [ ] Vrije notities alleen in tijdlijn
**Performance Tests:**
- [ ] Modal open <100ms
- [ ] AI response <2 sec (p95)
- [ ] Save operation <500ms
- [ ] Timeline render <200ms (10 items)
**Edge Cases:**
- [ ] Netwerk timeout handling
- [ ] AI parse error handling
- [ ] Concurrent edits (multiple tabs)
- [ ] Very long text (4900 chars)
- [ ] Special characters (emoji, unicode)
**Accessibility:**
- [ ] Keyboard navigatie werkt
- [ ] Screen reader compatible
- [ ] Focus states duidelijk
- [ ] ARIA labels correct
**Cross-browser:**
- [ ] Chrome/Edge latest
- [ ] Firefox latest
- [ ] Safari 14+
- [ ] Mobile Chrome/Safari
**Demo Readiness:**
- [ ] 30-sec demo scenario werkt smooth
- [ ] No bugs in happy flow
- [ ] Visual polish (animaties, spacing)
- [ ] Recording-ready UI
---
## 13. Roadmap & Uitbreidingen
🎯 **Doel:** Toekomstige iteraties en uitbreidmogelijkheden.
### Week 3 (Voice + Types)
- 🎤 Deepgram voice input integration
- 📋 Meer rapportage types (intake, voortgang, crisis, contact)
- 🔍 Filter/zoek functionaliteit in tijdlijn
- ⌨️ Keyboard shortcut (Cmd+Shift+R)
- 📊 Structured data extraction (DSM codes, medicatie)
### Week 4 (Polish + Analytics)
- ✨ Rich editor formatting in rapportages
- 📈 Dashboard widgets (rapportage stats)
- 🔔 Notificaties bij crisis rapportages
- 📄 PDF export van rapportages
- 🎬 Marketing video + LinkedIn posts
### Post-MVP (Future)
- 🧠 AI leren van correcties (feedback loop)
- 🔗 Cross-client insights (anonymized patterns)
- 📱 Mobile app (React Native)
- 🌐 Multi-language support (Engels)
- 🔐 Advanced permissions (team-based access)
- 📊 BI dashboard voor managers
- 🔄 Integraties (Zorgdomein, Ggz Standaarden)
---
## 14. Bijlagen & Referenties
🎯 **Doel:** linken naar de overige documenten binnen Mission Control.
**Interne documenten:**
- [PRD v1.2](./prd-mini-ecd-v1_2.md) - Product Requirements Document
- [TO v1.2](./to-mini-ecd-v1_2.md) - Technisch Ontwerp
- [FO v2.0](./fo-mini-ecd-v2.md) - Functioneel Ontwerp (basis applicatie)
- [FO Live Transcriptie](./fo-live-transcriptie-v1.md) - Voice input (Week 3)
- [UX Stylesheet](./ux-stylesheet.md) - Design system
- [Bouwplan v1.1](./bouwplan-ai-speedrun-v1.md) - Development roadmap
- [API Access](./api-access-mini-ecd.md) - Credentials en setup
**Externe referenties:**
- [Anthropic Claude API](https://docs.anthropic.com/claude/reference)
- [Supabase Docs](https://supabase.com/docs)
- [Next.js 15 Docs](https://nextjs.org/docs)
- [shadcn/ui Components](https://ui.shadcn.com/)
**Demo assets:**
- LinkedIn post templates
- 30-second demo script
- Test rapportages (NL samples)
- Figma wireframes (optional)
---
**Versiehistorie:**
| Versie | Datum | Auteur | Wijziging |
|--------|-------|--------|-----------|
| v1.0 | 23-11-2024 | Colin van der Heijden | Initiële versie - Week 2 MVP scope |
---
**Einde Functioneel Ontwerp - Universele Rapportage Functie**

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,93 @@
# Universele Rapportage — Split Timeline Composer
Context: gesprek met behandelaren/psychiaters toonde dat rapportages schrijven en overzicht houden tegelijk nodig is. Deze mock beschrijft een layout waarbij timeline en composer gelijktijdig zichtbaar blijven.
## UX Principes
1. **Context behouden** timeline altijd zichtbaar; geen modal die context blokkeert.
2. **Snelle wissel** klik op entry synchroniseert composer en toont referenties.
3. **Mobiele pariteit** tabs/drawer zodat schrijven en lezen in één flow blijft.
4. **State veilig** concepten autosaven; switching verliest content niet.
## Desktop Mock
```
┌───────────────────────────────┬───────────────────────────────┐
│ Timeline kolom (40%) │ Composer kolom (60%) │
│ ┌────────── Rapportage filter │ ┌──────────── Nieuwe rapportage│
│ │ Zoeken [🔍___________] │ │ Emma de Vries • Vrije notitie│
│ │ Type [• Alle ▼] │ │ --------------------------- │
│ └──────────────────────────── │ │ [textarea................] │
│ ┌── Entry ─────────────────── │ │ AI badge + dropdown │
│ │ 09:12 • Sophie (AI 92%) │ │ Rapportagetype [▼] │
│ │ Behandeladvies snippet │ │ Speech recorder + attach │
│ └──────────────────────────── │ │ Footer: Opslaan • Analyseer │
│ Scrollbare lijst met badges │ │ Sticky action bar │
└───────────────────────────────┴───────────────────────────────┘
```
- Clicking an entry previews full text in a slide-out panel or inline expansion.
- Filters (type, author, date) pin to top of timeline.
- Composer sticky footer shows save status + autosave timestamp.
## Mobile Mock
```
┌──────────────┐
│ Tabs │
│ [Timeline] [Rapportage+] │
├──────────────┤
│ Timeline tab │
│ cards stack │
└────┬─────────┘
▼ swipe up opens composer drawer
┌──────────────┐
│ Rapportage │
│ textarea │
│ AI panel │
│ Save / AI btn│
└──────────────┘
```
- Drawer covers 75% height so top timeline remains slightly visible.
- Swipe down or "Terug naar timeline" button closes drawer without losing draft.
## Interacties
- **Select entry → composer reference**: clicking "Referentie toevoegen" inserts a quote block with metadata (author, time).
- **Autosave badge**: top-right of composer shows "Automatisch opgeslagen 13:42".
- **AI context chips**: timeline entries show AI confidence badges; clicking them filters timeline by type.
## Open vragen
1. Hoeveel entries tonen we per pagina? (voorstel: 50 + virtuele scroll)
2. Benodigd? inline edit van bestaande rapportages? (nu read-only)
3. Notificatie bij gelijktijdig schrijven door meerdere behandelaren?
Deze mock dient als richting voor UI implementatie; visuele uitwerking kan in Figma plaatsvinden met shadcn/ui componenten.
## Actieplan
1. **UX refinement**
- Bouw Figma-variant voor desktop split view en mobiele drawer.
- Valideer met 2 behandelaren of de voorgestelde flows voldoen.
2. **Component architectuur**
- Refactor rapportage route naar layout met `TimelinePane` + `ComposerPane`.
- Introduceer context/state voor draft + selectie (bijv. `useReportDraft`).
3. **Timeline functionaliteit**
- Implement filters (zoek, type, auteur, datum).
- Voeg inline preview/slide-out voor entries toe.
- Onderzoek virtualized list (React Virtual) voor >50 items.
4. **Composer upgrades**
- Sticky footer met status + autosave indicator.
- "Referentie toevoegen" waarmee geselecteerde entry als quote block wordt ingevoegd.
- Drawer/tab ervaring op mobiel.
5. **State & autosave**
- Drafts opslaan in localStorage of Supabase temp table.
- Toon laatste autosave + unsaved changes waarschuwing bij navigeren.
6. **Testing & rollout**
- Schrijf QA checklist (desktop/mobile).
- Monitor gebruikersfeedback tijdens pilot, focus op contextswitch-snelheid.

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

194
hooks/use-toast.ts Normal file
View File

@@ -0,0 +1,194 @@
"use client"
// Inspired by react-hot-toast library
import * as React from "react"
import type {
ToastActionElement,
ToastProps,
} from "@/components/ui/toast"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
case "DISMISS_TOAST": {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, "id">
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
}
export { useToast, toast }

46
lib/server/api-client.ts Normal file
View File

@@ -0,0 +1,46 @@
import { headers, cookies } from 'next/headers';
export function getBaseUrl(): string {
if (process.env.NEXT_PUBLIC_APP_URL) {
return process.env.NEXT_PUBLIC_APP_URL;
}
try {
const headersList = headers();
const host = headersList.get('host');
const protocol = headersList.get('x-forwarded-proto') || 'http';
if (host) {
return `${protocol}://${host}`;
}
} catch {
// Ignore header errors
}
return 'http://localhost:3000';
}
export async function getCookieHeader(): Promise<string> {
try {
const cookieStore = await cookies();
return cookieStore
.getAll()
.map((cookie) => `${cookie.name}=${cookie.value}`)
.join('; ');
} catch {
return '';
}
}
export async function authFetch(input: string | URL, init: RequestInit = {}) {
const cookieHeader = await getCookieHeader();
const headersObj = new Headers(init.headers);
if (cookieHeader && !headersObj.has('cookie')) {
headersObj.set('cookie', cookieHeader);
}
return fetch(input, {
...init,
headers: headersObj,
});
}

File diff suppressed because it is too large Load Diff

35
lib/types/report.ts Normal file
View File

@@ -0,0 +1,35 @@
import { z } from 'zod';
import type { Database } from '@/lib/supabase/database.types';
export type Report = Database['public']['Tables']['reports']['Row'];
export type ReportInsert = Database['public']['Tables']['reports']['Insert'];
export type ReportUpdate = Database['public']['Tables']['reports']['Update'];
export const REPORT_TYPES = ['behandeladvies', 'vrije_notitie'] as const;
export type ReportType = (typeof REPORT_TYPES)[number];
export const CreateReportSchema = z.object({
patient_id: z.string().uuid('Patient ID moet een geldige UUID zijn'),
type: z.enum(REPORT_TYPES, {
errorMap: () => ({ message: 'Type moet behandeladvies of vrije_notitie zijn' }),
}),
content: z
.string()
.min(20, 'Rapportage moet minimaal 20 karakters bevatten')
.max(5000, 'Rapportage mag maximaal 5000 karakters bevatten'),
ai_confidence: z.number().min(0).max(1).optional(),
ai_reasoning: z.string().optional(),
});
export type CreateReportInput = z.infer<typeof CreateReportSchema>;
export interface ClassificationResult {
type: ReportType;
confidence: number;
reasoning?: string;
}
export interface ReportListResponse {
reports: Report[];
total: number;
}

View File

@@ -12,8 +12,11 @@
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-toast": "^1.2.15",
"@react-three/drei": "^10.7.7", "@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.4.0", "@react-three/fiber": "^9.4.0",
"@supabase/ssr": "^0.7.0", "@supabase/ssr": "^0.7.0",

718
pnpm-lock.yaml generated
View File

@@ -11,12 +11,21 @@ importers:
'@hookform/resolvers': '@hookform/resolvers':
specifier: ^5.2.2 specifier: ^5.2.2
version: 5.2.2(react-hook-form@7.66.1(react@18.3.1)) version: 5.2.2(react-hook-form@7.66.1(react@18.3.1))
'@radix-ui/react-dialog':
specifier: ^1.1.15
version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-dropdown-menu':
specifier: ^2.1.16
version: 2.1.16(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-icons': '@radix-ui/react-icons':
specifier: ^1.3.2 specifier: ^1.3.2
version: 1.3.2(react@18.3.1) version: 1.3.2(react@18.3.1)
'@radix-ui/react-slot': '@radix-ui/react-slot':
specifier: ^1.2.4 specifier: ^1.2.4
version: 1.2.4(@types/react@18.3.27)(react@18.3.1) version: 1.2.4(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-toast':
specifier: ^1.2.15
version: 1.2.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@react-three/drei': '@react-three/drei':
specifier: ^10.7.7 specifier: ^10.7.7
version: 10.7.7(@react-three/fiber@9.4.0(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.181.2))(@types/react@18.3.27)(@types/three@0.181.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.181.2) version: 10.7.7(@react-three/fiber@9.4.0(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.181.2))(@types/react@18.3.27)(@types/three@0.181.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.181.2)
@@ -340,6 +349,12 @@ packages:
'@floating-ui/dom@1.7.4': '@floating-ui/dom@1.7.4':
resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==} resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==}
'@floating-ui/react-dom@2.1.6':
resolution: {integrity: sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@floating-ui/utils@0.2.10': '@floating-ui/utils@0.2.10':
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
@@ -481,6 +496,35 @@ packages:
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'} engines: {node: '>=14'}
'@radix-ui/primitive@1.1.3':
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
'@radix-ui/react-arrow@1.1.7':
resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-collection@1.1.7':
resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-compose-refs@1.1.2': '@radix-ui/react-compose-refs@1.1.2':
resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==}
peerDependencies: peerDependencies:
@@ -490,11 +534,186 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
'@radix-ui/react-context@1.1.2':
resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-dialog@1.1.15':
resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-direction@1.1.1':
resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-dismissable-layer@1.1.11':
resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-dropdown-menu@2.1.16':
resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-focus-guards@1.1.3':
resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-focus-scope@1.1.7':
resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-icons@1.3.2': '@radix-ui/react-icons@1.3.2':
resolution: {integrity: sha512-fyQIhGDhzfc9pK2kH6Pl9c4BDJGfMkPqkyIgYDthyNYoNg3wVhoJMMh19WS4Up/1KMPFVpNsT2q3WmXn2N1m6g==} resolution: {integrity: sha512-fyQIhGDhzfc9pK2kH6Pl9c4BDJGfMkPqkyIgYDthyNYoNg3wVhoJMMh19WS4Up/1KMPFVpNsT2q3WmXn2N1m6g==}
peerDependencies: peerDependencies:
react: ^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc react: ^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc
'@radix-ui/react-id@1.1.1':
resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-menu@2.1.16':
resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-popper@1.2.8':
resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-portal@1.1.9':
resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-presence@1.1.5':
resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-primitive@2.1.3':
resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-roving-focus@1.1.11':
resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-slot@1.2.3':
resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-slot@1.2.4': '@radix-ui/react-slot@1.2.4':
resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==}
peerDependencies: peerDependencies:
@@ -504,6 +723,98 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
'@radix-ui/react-toast@1.2.15':
resolution: {integrity: sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-use-callback-ref@1.1.1':
resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-controllable-state@1.2.2':
resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-effect-event@0.0.2':
resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-escape-keydown@1.1.1':
resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-layout-effect@1.1.1':
resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-rect@1.1.1':
resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-size@1.1.1':
resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-visually-hidden@1.2.3':
resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/rect@1.1.1':
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
'@react-three/drei@10.7.7': '@react-three/drei@10.7.7':
resolution: {integrity: sha512-ff+J5iloR0k4tC++QtD/j9u3w5fzfgFAWDtAGQah9pF2B1YgOq/5JxqY0/aVoQG5r3xSZz0cv5tk2YuBob4xEQ==} resolution: {integrity: sha512-ff+J5iloR0k4tC++QtD/j9u3w5fzfgFAWDtAGQah9pF2B1YgOq/5JxqY0/aVoQG5r3xSZz0cv5tk2YuBob4xEQ==}
peerDependencies: peerDependencies:
@@ -1135,6 +1446,10 @@ packages:
argparse@2.0.1: argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
aria-hidden@1.2.6:
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
engines: {node: '>=10'}
aria-query@5.3.2: aria-query@5.3.2:
resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -1409,6 +1724,9 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
detect-node-es@1.1.0:
resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
devlop@1.1.0: devlop@1.1.0:
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
@@ -1754,6 +2072,10 @@ packages:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
get-nonce@1.0.1:
resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==}
engines: {node: '>=6'}
get-proto@1.0.1: get-proto@1.0.1:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -2695,6 +3017,36 @@ packages:
peerDependencies: peerDependencies:
react: ^19.0.0 react: ^19.0.0
react-remove-scroll-bar@2.3.8:
resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
engines: {node: '>=10'}
peerDependencies:
'@types/react': '*'
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@types/react':
optional: true
react-remove-scroll@2.7.1:
resolution: {integrity: sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==}
engines: {node: '>=10'}
peerDependencies:
'@types/react': '*'
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
react-style-singleton@2.2.3:
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
engines: {node: '>=10'}
peerDependencies:
'@types/react': '*'
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
react-use-measure@2.1.7: react-use-measure@2.1.7:
resolution: {integrity: sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==} resolution: {integrity: sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==}
peerDependencies: peerDependencies:
@@ -3154,6 +3506,26 @@ packages:
uri-js@4.4.1: uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
use-callback-ref@1.3.3:
resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==}
engines: {node: '>=10'}
peerDependencies:
'@types/react': '*'
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
use-sidecar@1.1.3:
resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==}
engines: {node: '>=10'}
peerDependencies:
'@types/react': '*'
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
use-sync-external-store@1.6.0: use-sync-external-store@1.6.0:
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
peerDependencies: peerDependencies:
@@ -3416,16 +3788,19 @@ snapshots:
'@floating-ui/core@1.7.3': '@floating-ui/core@1.7.3':
dependencies: dependencies:
'@floating-ui/utils': 0.2.10 '@floating-ui/utils': 0.2.10
optional: true
'@floating-ui/dom@1.7.4': '@floating-ui/dom@1.7.4':
dependencies: dependencies:
'@floating-ui/core': 1.7.3 '@floating-ui/core': 1.7.3
'@floating-ui/utils': 0.2.10 '@floating-ui/utils': 0.2.10
optional: true
'@floating-ui/utils@0.2.10': '@floating-ui/react-dom@2.1.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
optional: true dependencies:
'@floating-ui/dom': 1.7.4
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@floating-ui/utils@0.2.10': {}
'@hookform/resolvers@5.2.2(react-hook-form@7.66.1(react@18.3.1))': '@hookform/resolvers@5.2.2(react-hook-form@7.66.1(react@18.3.1))':
dependencies: dependencies:
@@ -3572,16 +3947,222 @@ snapshots:
'@pkgjs/parseargs@0.11.0': '@pkgjs/parseargs@0.11.0':
optional: true optional: true
'@radix-ui/primitive@1.1.3': {}
'@radix-ui/react-arrow@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.27
'@types/react-dom': 18.3.7(@types/react@18.3.27)
'@radix-ui/react-collection@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-slot': 1.2.3(@types/react@18.3.27)(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.27
'@types/react-dom': 18.3.7(@types/react@18.3.27)
'@radix-ui/react-compose-refs@1.1.2(@types/react@18.3.27)(react@18.3.1)': '@radix-ui/react-compose-refs@1.1.2(@types/react@18.3.27)(react@18.3.1)':
dependencies: dependencies:
react: 18.3.1 react: 18.3.1
optionalDependencies: optionalDependencies:
'@types/react': 18.3.27 '@types/react': 18.3.27
'@radix-ui/react-context@1.1.2(@types/react@18.3.27)(react@18.3.1)':
dependencies:
react: 18.3.1
optionalDependencies:
'@types/react': 18.3.27
'@radix-ui/react-dialog@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-id': 1.1.1(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-slot': 1.2.3(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.27)(react@18.3.1)
aria-hidden: 1.2.6
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react-remove-scroll: 2.7.1(@types/react@18.3.27)(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.27
'@types/react-dom': 18.3.7(@types/react@18.3.27)
'@radix-ui/react-direction@1.1.1(@types/react@18.3.27)(react@18.3.1)':
dependencies:
react: 18.3.1
optionalDependencies:
'@types/react': 18.3.27
'@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@18.3.27)(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.27
'@types/react-dom': 18.3.7(@types/react@18.3.27)
'@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-id': 1.1.1(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-menu': 2.1.16(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.27)(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.27
'@types/react-dom': 18.3.7(@types/react@18.3.27)
'@radix-ui/react-focus-guards@1.1.3(@types/react@18.3.27)(react@18.3.1)':
dependencies:
react: 18.3.1
optionalDependencies:
'@types/react': 18.3.27
'@radix-ui/react-focus-scope@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.27)(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.27
'@types/react-dom': 18.3.7(@types/react@18.3.27)
'@radix-ui/react-icons@1.3.2(react@18.3.1)': '@radix-ui/react-icons@1.3.2(react@18.3.1)':
dependencies: dependencies:
react: 18.3.1 react: 18.3.1
'@radix-ui/react-id@1.1.1(@types/react@18.3.27)(react@18.3.1)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.27)(react@18.3.1)
react: 18.3.1
optionalDependencies:
'@types/react': 18.3.27
'@radix-ui/react-menu@2.1.16(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-direction': 1.1.1(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-id': 1.1.1(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-slot': 1.2.3(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.27)(react@18.3.1)
aria-hidden: 1.2.6
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react-remove-scroll: 2.7.1(@types/react@18.3.27)(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.27
'@types/react-dom': 18.3.7(@types/react@18.3.27)
'@radix-ui/react-popper@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@floating-ui/react-dom': 2.1.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-arrow': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-use-rect': 1.1.1(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-use-size': 1.1.1(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/rect': 1.1.1
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.27
'@types/react-dom': 18.3.7(@types/react@18.3.27)
'@radix-ui/react-portal@1.1.9(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.27)(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.27
'@types/react-dom': 18.3.7(@types/react@18.3.27)
'@radix-ui/react-presence@1.1.5(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.27)(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.27
'@types/react-dom': 18.3.7(@types/react@18.3.27)
'@radix-ui/react-primitive@2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/react-slot': 1.2.3(@types/react@18.3.27)(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.27
'@types/react-dom': 18.3.7(@types/react@18.3.27)
'@radix-ui/react-roving-focus@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-direction': 1.1.1(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-id': 1.1.1(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.27)(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.27
'@types/react-dom': 18.3.7(@types/react@18.3.27)
'@radix-ui/react-slot@1.2.3(@types/react@18.3.27)(react@18.3.1)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1)
react: 18.3.1
optionalDependencies:
'@types/react': 18.3.27
'@radix-ui/react-slot@1.2.4(@types/react@18.3.27)(react@18.3.1)': '@radix-ui/react-slot@1.2.4(@types/react@18.3.27)(react@18.3.1)':
dependencies: dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1)
@@ -3589,6 +4170,85 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 18.3.27 '@types/react': 18.3.27
'@radix-ui/react-toast@1.2.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.27
'@types/react-dom': 18.3.7(@types/react@18.3.27)
'@radix-ui/react-use-callback-ref@1.1.1(@types/react@18.3.27)(react@18.3.1)':
dependencies:
react: 18.3.1
optionalDependencies:
'@types/react': 18.3.27
'@radix-ui/react-use-controllable-state@1.2.2(@types/react@18.3.27)(react@18.3.1)':
dependencies:
'@radix-ui/react-use-effect-event': 0.0.2(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.27)(react@18.3.1)
react: 18.3.1
optionalDependencies:
'@types/react': 18.3.27
'@radix-ui/react-use-effect-event@0.0.2(@types/react@18.3.27)(react@18.3.1)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.27)(react@18.3.1)
react: 18.3.1
optionalDependencies:
'@types/react': 18.3.27
'@radix-ui/react-use-escape-keydown@1.1.1(@types/react@18.3.27)(react@18.3.1)':
dependencies:
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.27)(react@18.3.1)
react: 18.3.1
optionalDependencies:
'@types/react': 18.3.27
'@radix-ui/react-use-layout-effect@1.1.1(@types/react@18.3.27)(react@18.3.1)':
dependencies:
react: 18.3.1
optionalDependencies:
'@types/react': 18.3.27
'@radix-ui/react-use-rect@1.1.1(@types/react@18.3.27)(react@18.3.1)':
dependencies:
'@radix-ui/rect': 1.1.1
react: 18.3.1
optionalDependencies:
'@types/react': 18.3.27
'@radix-ui/react-use-size@1.1.1(@types/react@18.3.27)(react@18.3.1)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.27)(react@18.3.1)
react: 18.3.1
optionalDependencies:
'@types/react': 18.3.27
'@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.27
'@types/react-dom': 18.3.7(@types/react@18.3.27)
'@radix-ui/rect@1.1.1': {}
'@react-three/drei@10.7.7(@react-three/fiber@9.4.0(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.181.2))(@types/react@18.3.27)(@types/three@0.181.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.181.2)': '@react-three/drei@10.7.7(@react-three/fiber@9.4.0(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.181.2))(@types/react@18.3.27)(@types/three@0.181.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.181.2)':
dependencies: dependencies:
'@babel/runtime': 7.28.4 '@babel/runtime': 7.28.4
@@ -4245,6 +4905,10 @@ snapshots:
argparse@2.0.1: {} argparse@2.0.1: {}
aria-hidden@1.2.6:
dependencies:
tslib: 2.8.1
aria-query@5.3.2: {} aria-query@5.3.2: {}
array-buffer-byte-length@1.0.2: array-buffer-byte-length@1.0.2:
@@ -4532,6 +5196,8 @@ snapshots:
detect-libc@2.1.2: {} detect-libc@2.1.2: {}
detect-node-es@1.1.0: {}
devlop@1.1.0: devlop@1.1.0:
dependencies: dependencies:
dequal: 2.0.3 dequal: 2.0.3
@@ -5060,6 +5726,8 @@ snapshots:
hasown: 2.0.2 hasown: 2.0.2
math-intrinsics: 1.1.0 math-intrinsics: 1.1.0
get-nonce@1.0.1: {}
get-proto@1.0.1: get-proto@1.0.1:
dependencies: dependencies:
dunder-proto: 1.0.1 dunder-proto: 1.0.1
@@ -6239,6 +6907,33 @@ snapshots:
react: 18.3.1 react: 18.3.1
scheduler: 0.25.0 scheduler: 0.25.0
react-remove-scroll-bar@2.3.8(@types/react@18.3.27)(react@18.3.1):
dependencies:
react: 18.3.1
react-style-singleton: 2.2.3(@types/react@18.3.27)(react@18.3.1)
tslib: 2.8.1
optionalDependencies:
'@types/react': 18.3.27
react-remove-scroll@2.7.1(@types/react@18.3.27)(react@18.3.1):
dependencies:
react: 18.3.1
react-remove-scroll-bar: 2.3.8(@types/react@18.3.27)(react@18.3.1)
react-style-singleton: 2.2.3(@types/react@18.3.27)(react@18.3.1)
tslib: 2.8.1
use-callback-ref: 1.3.3(@types/react@18.3.27)(react@18.3.1)
use-sidecar: 1.1.3(@types/react@18.3.27)(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.27
react-style-singleton@2.2.3(@types/react@18.3.27)(react@18.3.1):
dependencies:
get-nonce: 1.0.1
react: 18.3.1
tslib: 2.8.1
optionalDependencies:
'@types/react': 18.3.27
react-use-measure@2.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1): react-use-measure@2.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies: dependencies:
react: 18.3.1 react: 18.3.1
@@ -6857,6 +7552,21 @@ snapshots:
dependencies: dependencies:
punycode: 2.3.1 punycode: 2.3.1
use-callback-ref@1.3.3(@types/react@18.3.27)(react@18.3.1):
dependencies:
react: 18.3.1
tslib: 2.8.1
optionalDependencies:
'@types/react': 18.3.27
use-sidecar@1.1.3(@types/react@18.3.27)(react@18.3.1):
dependencies:
detect-node-es: 1.1.0
react: 18.3.1
tslib: 2.8.1
optionalDependencies:
'@types/react': 18.3.27
use-sync-external-store@1.6.0(react@18.3.1): use-sync-external-store@1.6.0(react@18.3.1):
dependencies: dependencies:
react: 18.3.1 react: 18.3.1

View File

@@ -0,0 +1,109 @@
-- Migration: create reports table for universal reporting
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE TABLE IF NOT EXISTS reports (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
patient_id UUID NOT NULL REFERENCES patients(id) ON DELETE CASCADE,
created_by UUID REFERENCES practitioners(id),
type TEXT NOT NULL CHECK (type IN (
'behandeladvies',
'vrije_notitie',
'intake',
'voortgang',
'crisis',
'contact'
)),
content TEXT NOT NULL CHECK (char_length(content) >= 20),
ai_confidence NUMERIC(3,2) CHECK (ai_confidence >= 0 AND ai_confidence <= 1),
ai_reasoning TEXT,
structured_data JSONB DEFAULT '{}'::jsonb,
audio_url TEXT,
audio_duration_seconds INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ,
updated_by UUID REFERENCES practitioners(id),
version TEXT,
parent_report_id UUID REFERENCES reports(id),
deleted_at TIMESTAMPTZ,
CONSTRAINT reports_content_length CHECK (char_length(content) <= 5000)
);
CREATE INDEX IF NOT EXISTS idx_reports_patient_id ON reports(patient_id)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_reports_type ON reports(type)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_reports_created_at ON reports(created_at DESC)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_reports_created_by ON reports(created_by);
CREATE INDEX IF NOT EXISTS idx_reports_patient_created ON reports(patient_id, created_at DESC)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_reports_content_fts ON reports USING gin(to_tsvector('dutch', content))
WHERE deleted_at IS NULL;
ALTER TABLE reports ENABLE ROW LEVEL SECURITY;
CREATE POLICY reports_select_policy ON reports
FOR SELECT
USING (
EXISTS (
SELECT 1
FROM practitioners p
WHERE p.id = reports.created_by
AND p.user_id = auth.uid()
)
);
CREATE POLICY reports_insert_policy ON reports
FOR INSERT
WITH CHECK (
EXISTS (
SELECT 1
FROM practitioners p
WHERE p.id = reports.created_by
AND p.user_id = auth.uid()
)
);
CREATE POLICY reports_update_policy ON reports
FOR UPDATE
USING (
EXISTS (
SELECT 1
FROM practitioners p
WHERE p.id = reports.created_by
AND p.user_id = auth.uid()
)
)
WITH CHECK (
EXISTS (
SELECT 1
FROM practitioners p
WHERE p.id = reports.created_by
AND p.user_id = auth.uid()
)
);
CREATE POLICY reports_delete_policy ON reports
FOR UPDATE
USING (
EXISTS (
SELECT 1
FROM practitioners p
WHERE p.id = reports.created_by
AND p.user_id = auth.uid()
)
AND deleted_at IS NULL
)
WITH CHECK (deleted_at IS NOT NULL);
COMMENT ON TABLE reports IS 'Universal reporting system - AI-powered classification';
COMMENT ON COLUMN reports.type IS 'Report type: behandeladvies, vrije_notitie, etc.';
COMMENT ON COLUMN reports.ai_confidence IS 'AI classification confidence (0.0-1.0)';
COMMENT ON COLUMN reports.structured_data IS 'Extracted entities (DSM codes, medication, etc.)';
COMMENT ON COLUMN reports.audio_url IS 'Supabase Storage URL for audio recording';

View File

@@ -0,0 +1,33 @@
-- Migration: Fix RLS policies for reports table (prototype - open access)
-- All authenticated users can read/write all reports
-- Drop existing restrictive policies
DROP POLICY IF EXISTS reports_select_policy ON reports;
DROP POLICY IF EXISTS reports_insert_policy ON reports;
DROP POLICY IF EXISTS reports_update_policy ON reports;
DROP POLICY IF EXISTS reports_delete_policy ON reports;
-- Create simple policies: all authenticated users can do everything
CREATE POLICY reports_select_policy ON reports
FOR SELECT
USING (auth.role() = 'authenticated');
CREATE POLICY reports_insert_policy ON reports
FOR INSERT
WITH CHECK (auth.role() = 'authenticated');
CREATE POLICY reports_update_policy ON reports
FOR UPDATE
USING (auth.role() = 'authenticated')
WITH CHECK (auth.role() = 'authenticated');
CREATE POLICY reports_delete_policy ON reports
FOR UPDATE
USING (auth.role() = 'authenticated' AND deleted_at IS NULL)
WITH CHECK (auth.role() = 'authenticated' AND deleted_at IS NOT NULL);
-- Comment for documentation
COMMENT ON POLICY reports_select_policy ON reports IS 'Prototype: All authenticated users can read all reports';
COMMENT ON POLICY reports_insert_policy ON reports IS 'Prototype: All authenticated users can create reports';
COMMENT ON POLICY reports_update_policy ON reports IS 'Prototype: All authenticated users can update reports';
COMMENT ON POLICY reports_delete_policy ON reports IS 'Prototype: All authenticated users can soft-delete reports';