diff --git a/app/epd/patients/[id]/components/client-header.tsx b/app/epd/patients/[id]/components/client-header.tsx index fede5b1..a85ee70 100644 --- a/app/epd/patients/[id]/components/client-header.tsx +++ b/app/epd/patients/[id]/components/client-header.tsx @@ -6,12 +6,13 @@ */ import { Mic } from 'lucide-react'; +import { useRouter, usePathname } from 'next/navigation'; import type { FHIRPatient } from '@/lib/fhir'; import { Button } from '@/components/ui/button'; interface ClientHeaderProps { patient: FHIRPatient; - onNewReport?: () => void; + focusElementId?: string; } // Status badge component @@ -38,7 +39,23 @@ function StatusBadge({ status }: { status?: string }) { ); } -export function ClientHeader({ patient, onNewReport }: ClientHeaderProps) { +export function ClientHeader({ patient, focusElementId = 'rapportage-composer' }: ClientHeaderProps) { + const router = useRouter(); + const pathname = usePathname(); + + const handleNewReportClick = (patientId?: string) => { + if (!patientId) return; + const rapportagePath = `/epd/patients/${patientId}/rapportage`; + const onRapportagePage = pathname?.startsWith(rapportagePath); + + if (onRapportagePage) { + handleScrollToComposer(focusElementId); + return; + } + + const hash = focusElementId ? `#${focusElementId}` : ''; + router.push(`${rapportagePath}${hash}`); + }; // Extract name const name = patient.name?.[0]; const fullName = [ @@ -104,13 +121,20 @@ export function ClientHeader({ patient, onNewReport }: ClientHeaderProps) {
ID: {patient.id}
- {onNewReport && ( - - )} + ); } + +function handleScrollToComposer(targetId?: string) { + if (!targetId) return; + const element = document.getElementById(targetId); + if (element) { + element.scrollIntoView({ behavior: 'smooth', block: 'start' }); + (element as HTMLElement).focus?.(); + } +} diff --git a/app/epd/patients/[id]/rapportage/actions.ts b/app/epd/patients/[id]/rapportage/actions.ts new file mode 100644 index 0000000..7a168ae --- /dev/null +++ b/app/epd/patients/[id]/rapportage/actions.ts @@ -0,0 +1,75 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { redirect } from 'next/navigation'; +import { authFetch, getBaseUrl } from '@/lib/server/api-client'; +import type { Report, ReportListResponse, CreateReportInput } from '@/lib/types/report'; + +export async function getReports(patientId: string): Promise { + const baseUrl = getBaseUrl(); + const url = new URL('/api/reports', baseUrl); + url.searchParams.set('patientId', patientId); + + const response = await authFetch(url.toString(), { + cache: 'no-store', + }); + + if (!response.ok) { + if (response.status === 401) { + redirect('/login'); + } + throw new Error('Fout bij ophalen rapportages'); + } + + const data: ReportListResponse = await response.json(); + return data.reports; +} + +export async function createReport( + patientId: string, + input: Omit +): Promise { + const baseUrl = getBaseUrl(); + const url = `${baseUrl}/api/reports`; + + const response = await authFetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + patient_id: patientId, + ...input, + }), + }); + + if (!response.ok) { + if (response.status === 401) { + redirect('/login'); + } + const error = await response.json().catch(() => ({})); + throw new Error(error.error || 'Opslaan mislukt'); + } + + revalidatePath(`/epd/patients/${patientId}/rapportage`); + return response.json(); +} + +export async function deleteReport(patientId: string, reportId: string) { + const baseUrl = getBaseUrl(); + const url = `${baseUrl}/api/reports/${reportId}`; + + const response = await authFetch(url, { + method: 'DELETE', + }); + + if (!response.ok) { + if (response.status === 401) { + redirect('/login'); + } + throw new Error('Verwijderen mislukt'); + } + + revalidatePath(`/epd/patients/${patientId}/rapportage`); + return response.json(); +} diff --git a/app/epd/patients/[id]/rapportage/components/rapportage-workspace.tsx b/app/epd/patients/[id]/rapportage/components/rapportage-workspace.tsx new file mode 100644 index 0000000..c7890ec --- /dev/null +++ b/app/epd/patients/[id]/rapportage/components/rapportage-workspace.tsx @@ -0,0 +1,324 @@ +'use client'; + +import type { ReactNode } from 'react'; +import { useMemo, useState, useEffect } from 'react'; +import { Search, Filter, Sparkles, Timer } from 'lucide-react'; +import type { Report } from '@/lib/types/report'; +import { ReportTimeline } from './report-timeline'; +import { ReportComposer } from './report-composer'; +import { useMediaQuery } from '@/hooks/use-media-query'; + +interface RapportageWorkspaceProps { + patientId: string; + patientName: string; + initialReports: Report[]; +} + +const TYPE_LABELS: Record = { + behandeladvies: 'Behandeladvies', + vrije_notitie: 'Vrije notitie', +}; + +export function RapportageWorkspace({ patientId, patientName, initialReports }: RapportageWorkspaceProps) { + const [reports, setReports] = useState(initialReports); + const [search, setSearch] = useState(''); + const [typeFilter, setTypeFilter] = useState<'all' | keyof typeof TYPE_LABELS>('all'); + const [selectedReportId, setSelectedReportId] = useState(null); + const [authorFilter, setAuthorFilter] = useState<'all' | string>('all'); + const [dateFrom, setDateFrom] = useState(''); + const [dateTo, setDateTo] = useState(''); + const [aiFilter, setAiFilter] = useState<'all' | 'ai' | 'manual'>('all'); + const isMobile = useMediaQuery('(max-width: 1023px)'); + const [activeTab, setActiveTab] = useState<'timeline' | 'composer'>('timeline'); + + useEffect(() => { + if (!isMobile) { + setActiveTab('timeline'); + } + }, [isMobile]); + + const authorOptions = useMemo(() => { + const unique = Array.from(new Set(reports.map((report) => report.created_by).filter(Boolean))); + return unique as string[]; + }, [reports]); + + const filteredReports = useMemo(() => { + return reports.filter((report) => { + const matchesType = typeFilter === 'all' || report.type === typeFilter; + const matchesSearch = + !search || + report.content.toLowerCase().includes(search.toLowerCase()) || + report.ai_reasoning?.toLowerCase().includes(search.toLowerCase()); + const matchesAuthor = authorFilter === 'all' || report.created_by === authorFilter; + const createdAt = report.created_at ? new Date(report.created_at) : null; + const matchesFrom = !dateFrom || (createdAt && createdAt >= new Date(dateFrom)); + const matchesTo = !dateTo || (createdAt && createdAt <= new Date(`${dateTo}T23:59:59`)); + const matchesAI = + aiFilter === 'all' || + (aiFilter === 'ai' ? report.ai_confidence !== null : report.ai_confidence === null); + + return matchesType && matchesSearch && matchesAuthor && matchesFrom && matchesTo && matchesAI; + }); + }, [reports, search, typeFilter, authorFilter, dateFrom, dateTo, aiFilter]); + + const selectedReport = useMemo( + () => reports.find((report) => report.id === selectedReportId) ?? null, + [reports, selectedReportId] + ); + + const totalReports = reports.length; + const aiReports = reports.filter((report) => report.ai_confidence !== null).length; + const latestReport = reports[0]; + const latestDate = latestReport?.created_at + ? new Date(latestReport.created_at).toLocaleString('nl-NL') + : null; + + const handleReportCreated = (report: Report) => { + setReports((prev) => [report, ...prev]); + setSelectedReportId(report.id); + }; + + const handleDeleteSuccess = (reportId: string) => { + setReports((prev) => prev.filter((report) => report.id !== reportId)); + if (selectedReportId === reportId) { + setSelectedReportId(null); + } + }; + + const resetFilters = () => { + setSearch(''); + setTypeFilter('all'); + setAuthorFilter('all'); + setDateFrom(''); + setDateTo(''); + setAiFilter('all'); + }; + + return ( +
+
+

Universele rapportage

+

Tijdlijn en notities

+

+ Alle behandeladviezen en vrije notities voor {patientName} op één plek. +

+
+ +
+ } + label="Totaal rapportages" + value={totalReports.toString()} + helper={totalReports > 0 ? 'Inclusief AI-notities' : 'Nog geen rapportages'} + /> + } + label="AI classificaties" + value={aiReports.toString()} + helper="Aantal rapportages met AI-bijdrage" + /> + } + label="Laatste activiteit" + value={latestDate ?? '—'} + helper={latestDate ? 'Recentste rapportage' : 'Nog geen activiteit'} + /> +
+ + {isMobile && ( +
+ + +
+ )} + +
+ {(!isMobile || activeTab === 'timeline') && ( + + )} + + {(!isMobile || activeTab === 'composer') && ( +
+ {isMobile && activeTab === 'composer' && ( + + )} + +
+ )} +
+
+ ); +} + +function StatCard({ + icon, + label, + value, + helper, +}: { + icon: ReactNode; + label: string; + value: string; + helper: string; +}) { + return ( +
+
+ {label} +
{icon}
+
+

{value}

+

{helper}

+
+ ); +} + +function FilterChip({ active, onClick, children }: { active: boolean; onClick: () => void; children: ReactNode }) { + return ( + + ); +} diff --git a/app/epd/patients/[id]/rapportage/components/report-card.tsx b/app/epd/patients/[id]/rapportage/components/report-card.tsx index 30c373d..17cf703 100644 --- a/app/epd/patients/[id]/rapportage/components/report-card.tsx +++ b/app/epd/patients/[id]/rapportage/components/report-card.tsx @@ -24,9 +24,11 @@ interface ReportCardProps { report: Report; onDelete?: () => Promise | void; isDeleting?: boolean; + onSelect?: () => void; + isSelected?: boolean; } -export function ReportCard({ report, onDelete, isDeleting }: ReportCardProps) { +export function ReportCard({ report, onDelete, isDeleting, onSelect, isSelected }: ReportCardProps) { const meta = TYPE_META[report.type as keyof typeof TYPE_META] ?? TYPE_META.vrije_notitie; const Icon = meta.icon; const createdAt = report.created_at ? new Date(report.created_at) : null; @@ -35,7 +37,22 @@ export function ReportCard({ report, onDelete, isDeleting }: ReportCardProps) { : null; return ( -
+
onSelect?.()} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onSelect?.(); + } + }} + role={onSelect ? 'button' : undefined} + tabIndex={onSelect ? 0 : undefined} + >
void; +} + +export function ReportComposer({ + patientId, + patientName, + selectedReport, + onReportCreated, +}: ReportComposerProps) { + const router = useRouter(); + const [content, setContent] = useState(''); + const [classification, setClassification] = useState(null); + const [selectedType, setSelectedType] = useState<'behandeladvies' | 'vrije_notitie'>('vrije_notitie'); + const [isAnalyzing, setIsAnalyzing] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(null); + const [lastAutosave, setLastAutosave] = useState(null); + const draftStorageKey = useMemo(() => `rapportage-draft-${patientId}`, [patientId]); + + const characterCount = content.length; + const contentInvalid = characterCount < 20 || characterCount > 5000; + + useEffect(() => { + if (typeof window === 'undefined') return; + const stored = window.localStorage.getItem(draftStorageKey); + if (stored) { + try { + const draft = JSON.parse(stored) as { + content?: string; + type?: 'behandeladvies' | 'vrije_notitie'; + updatedAt?: string; + }; + if (draft.content) { + setContent(draft.content); + } + if (draft.type) { + setSelectedType(draft.type); + } + if (draft.updatedAt) { + setLastAutosave(new Date(draft.updatedAt)); + } + } catch (draftError) { + console.error('Failed to parse draft', draftError); + window.localStorage.removeItem(draftStorageKey); + } + } + }, [draftStorageKey]); + + useEffect(() => { + if (typeof window === 'undefined') return; + if (!content && selectedType === 'vrije_notitie') { + window.localStorage.removeItem(draftStorageKey); + setLastAutosave(null); + return; + } + + const timeout = window.setTimeout(() => { + const payload = { + content, + type: selectedType, + updatedAt: new Date().toISOString(), + }; + window.localStorage.setItem(draftStorageKey, JSON.stringify(payload)); + setLastAutosave(new Date(payload.updatedAt)); + }, 800); + + return () => window.clearTimeout(timeout); + }, [content, selectedType, draftStorageKey]); + + const referenceSnippet = useMemo(() => { + if (!selectedReport) return null; + const createdAt = selectedReport.created_at ? new Date(selectedReport.created_at) : null; + return { + preview: + selectedReport.content.length > 160 + ? `${selectedReport.content.slice(0, 160)}…` + : selectedReport.content, + meta: createdAt + ? `${createdAt.toLocaleDateString('nl-NL')} • ${createdAt.toLocaleTimeString('nl-NL')}` + : 'Onbekende datum', + type: selectedReport.type, + }; + }, [selectedReport]); + + const analyzeWithAI = 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) { + const message = err instanceof Error ? err.message : 'AI-analyse mislukt'; + setClassification(null); + setSelectedType('vrije_notitie'); + setError(message); + toast({ variant: 'destructive', title: 'AI-analyse mislukt', description: message }); + } finally { + setIsAnalyzing(false); + } + }; + + const saveReport = async () => { + setIsSaving(true); + setError(null); + try { + const created = 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.`, + }); + onReportCreated?.(created); + setContent(''); + setClassification(null); + setSelectedType('vrije_notitie'); + if (typeof window !== 'undefined') { + window.localStorage.removeItem(draftStorageKey); + } + setLastAutosave(null); + router.refresh(); + } 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 insertReference = () => { + if (!selectedReport || !referenceSnippet) return; + const prefix = content ? `${content.trim()}\n\n` : ''; + const block = `> ${referenceSnippet.preview}\n(${referenceSnippet.type} • ${referenceSnippet.meta})`; + setContent(`${prefix}${block}\n\n`); + }; + + return ( +
+
+

Nieuwe rapportage

+

Voor {patientName}

+

+ Schrijf vanuit tekst of spraak en gebruik AI voor typebepaling. +

+
+ + {referenceSnippet && ( +
+
+ Geselecteerde rapportage + {referenceSnippet.meta} +
+

{referenceSnippet.preview}

+
+ {referenceSnippet.type} + +
+
+ )} + +
+
+