feat: round out rapportage epic

This commit is contained in:
colinislit
2025-11-23 20:20:03 +01:00
parent 290d78b6d7
commit 24c43a693d
9 changed files with 1677 additions and 31 deletions

View File

@@ -2,13 +2,16 @@
/**
* Client Header Component
* E2.S3: Context-aware header showing client name, status, and last modified
* E2.S3: Context-aware header showing client name, status, last modified en acties
*/
import { Mic } from 'lucide-react';
import type { FHIRPatient } from '@/lib/fhir';
import { Button } from '@/components/ui/button';
interface ClientHeaderProps {
patient: FHIRPatient;
onNewReport?: () => void;
}
// Status badge component
@@ -35,7 +38,7 @@ function StatusBadge({ status }: { status?: string }) {
);
}
export function ClientHeader({ patient }: ClientHeaderProps) {
export function ClientHeader({ patient, onNewReport }: ClientHeaderProps) {
// Extract name
const name = patient.name?.[0];
const fullName = [
@@ -97,9 +100,15 @@ export function ClientHeader({ patient }: ClientHeaderProps) {
</div>
</div>
{/* Patient ID (subtle) */}
<div className="text-xs text-slate-400">
ID: {patient.id}
<div className="flex items-center gap-3">
<div className="text-xs text-slate-400">
ID: {patient.id}
</div>
{onNewReport && (
<Button type="button" size="sm" onClick={onNewReport}>
<Mic className="mr-2 h-4 w-4" /> Nieuwe rapportage
</Button>
)}
</div>
</div>
</div>

View File

@@ -0,0 +1,42 @@
'use client';
import { useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import type { FHIRPatient } from '@/lib/fhir';
import { ClientHeader } from './client-header';
import { RapportageModal } from '../rapportage/components/rapportage-modal';
interface PatientLayoutClientProps {
patient: FHIRPatient;
patientId: string;
children: ReactNode;
}
export function PatientLayoutClient({ patient, patientId, children }: PatientLayoutClientProps) {
const [isModalOpen, setModalOpen] = useState(false);
const patientName = useMemo(() => {
const name = patient.name?.[0];
if (!name) return 'deze patiënt';
return [
...(name.prefix || []),
...(name.given || []),
name.family,
]
.filter(Boolean)
.join(' ');
}, [patient]);
return (
<div className="flex h-full flex-1 flex-col bg-white">
<ClientHeader patient={patient} onNewReport={() => setModalOpen(true)} />
<div className="flex-1 overflow-auto bg-slate-50">{children}</div>
<RapportageModal
isOpen={isModalOpen}
onClose={() => setModalOpen(false)}
patientId={patientId}
patientName={patientName}
/>
</div>
);
}

View File

@@ -1,11 +1,20 @@
import type { ReactNode } from 'react';
import { getPatient } from '../actions';
import { PatientLayoutClient } from './components/patient-layout-client';
export default async function PatientDetailLayout({
children,
params,
}: {
children: React.ReactNode;
children: ReactNode;
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const patient = await getPatient(id);
return <>{children}</>;
return (
<PatientLayoutClient patientId={id} patient={patient}>
{children}
</PatientLayoutClient>
);
}

View File

@@ -0,0 +1,196 @@
'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

@@ -0,0 +1,109 @@
'use client';
import { Loader2, Trash2, FilePenLine, FileText } from 'lucide-react';
import { format, formatDistanceToNow } from 'date-fns';
import { nl } from 'date-fns/locale';
import { Button } from '@/components/ui/button';
import type { Report } from '@/lib/types/report';
import { cn } from '@/lib/utils';
const TYPE_META = {
behandeladvies: {
label: 'Behandeladvies',
classes: 'bg-teal-50 text-teal-800 border-teal-200',
icon: FilePenLine,
},
vrije_notitie: {
label: 'Vrije notitie',
classes: 'bg-slate-100 text-slate-700 border-slate-200',
icon: FileText,
},
} as const;
interface ReportCardProps {
report: Report;
onDelete?: () => Promise<void> | void;
isDeleting?: boolean;
}
export function ReportCard({ report, onDelete, isDeleting }: 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;
const relativeTime = createdAt
? formatDistanceToNow(createdAt, { addSuffix: true, locale: nl })
: null;
return (
<article className="rounded-xl border border-slate-200 bg-white p-5 shadow-sm">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<div
className={cn(
'flex h-12 w-12 items-center justify-center rounded-xl border bg-white',
meta.classes
)}
>
<Icon className="h-5 w-5" />
</div>
<div>
<div className="flex items-center gap-2">
<p className="text-sm font-semibold text-slate-900">{meta.label}</p>
{report.ai_confidence !== null && report.ai_confidence !== undefined && (
<span className="text-xs font-medium text-slate-500">
AI {Math.round(report.ai_confidence * 100)}%
</span>
)}
</div>
{createdAt && (
<p className="text-xs text-slate-500">
{format(createdAt, "d MMM yyyy 'om' HH:mm", { locale: nl })}
{relativeTime && <span className="ml-2">({relativeTime})</span>}
</p>
)}
{report.created_by && (
<p className="text-xs text-slate-400">Aangemaakt door {report.created_by}</p>
)}
</div>
</div>
{onDelete && (
<Button
type="button"
variant="ghost"
size="sm"
className="text-slate-500 hover:text-red-600"
disabled={isDeleting}
onClick={() => void onDelete()}
>
{isDeleting ? (
<>
<Loader2 className="mr-1 h-4 w-4 animate-spin" /> Verwijderen
</>
) : (
<>
<Trash2 className="mr-1 h-4 w-4" /> Verwijder
</>
)}
</Button>
)}
</div>
<p className="mt-4 text-sm leading-relaxed text-slate-700">
{truncateContent(report.content)}
</p>
{report.ai_reasoning && (
<div className="mt-4 rounded-lg bg-slate-50 p-3 text-xs text-slate-600">
<p className="font-semibold text-slate-800">AI toelichting</p>
<p className="mt-1 text-slate-600">{report.ai_reasoning}</p>
</div>
)}
</article>
);
}
function truncateContent(content: string, maxLength = 320) {
if (!content) return '';
if (content.length <= maxLength) return content;
return `${content.slice(0, maxLength)}`;
}

View File

@@ -0,0 +1,71 @@
'use client';
import { useEffect, useState } from 'react';
import { FilePlus2 } from 'lucide-react';
import type { Report } from '@/lib/types/report';
import { deleteReport } from '../actions';
import { ReportCard } from './report-card';
import { toast } from '@/hooks/use-toast';
interface ReportTimelineProps {
reports: Report[];
patientId: string;
}
export function ReportTimeline({ reports, patientId }: ReportTimelineProps) {
const [items, setItems] = useState(reports);
const [deletingId, setDeletingId] = useState<string | null>(null);
useEffect(() => {
setItems(reports);
}, [reports]);
const handleDelete = async (reportId: string) => {
setDeletingId(reportId);
try {
await deleteReport(patientId, reportId);
setItems((prev) => prev.filter((report) => report.id !== reportId));
toast({
title: 'Rapportage verwijderd',
description: 'De rapportage is verwijderd uit de tijdlijn.',
});
} catch (error) {
console.error('Failed to delete report', error);
toast({
variant: 'destructive',
title: 'Verwijderen mislukt',
description:
error instanceof Error ? error.message : 'Probeer het later opnieuw.',
});
} finally {
setDeletingId(null);
}
};
if (items.length === 0) {
return (
<div className="rounded-2xl border border-dashed border-slate-300 bg-slate-50 p-10 text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-white text-slate-400">
<FilePlus2 className="h-6 w-6" />
</div>
<p className="text-base font-semibold text-slate-900">Nog geen rapportages</p>
<p className="mt-1 text-sm text-slate-500">
Klik op "Nieuwe rapportage" in de header om de eerste rapportage vast te leggen.
</p>
</div>
);
}
return (
<div className="space-y-4">
{items.map((report) => (
<ReportCard
key={report.id}
report={report}
onDelete={() => handleDelete(report.id)}
isDeleting={deletingId === report.id}
/>
))}
</div>
);
}

View File

@@ -1,9 +1,9 @@
/**
* Rapportage Page
* E2.S3: Placeholder for rapportage functionality (future epic)
*/
import { FileBarChart } from 'lucide-react';
import type { ReactNode } from 'react';
import { format } from 'date-fns';
import { nl } from 'date-fns/locale';
import { ClipboardList, Sparkles, Timer } from 'lucide-react';
import { getReports } from './actions';
import { ReportTimeline } from './components/report-timeline';
export default async function RapportagePage({
params,
@@ -11,30 +11,68 @@ export default async function RapportagePage({
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const reports = await getReports(id);
const totalReports = reports.length;
const latestReport = reports[0];
const latestDate = latestReport?.created_at
? format(new Date(latestReport.created_at), "d MMM yyyy 'om' HH:mm", { locale: nl })
: null;
return (
<div className="p-6">
{/* Page Header */}
<div className="mb-6">
<h2 className="text-lg font-semibold text-slate-900">Rapportage</h2>
<p className="text-sm text-slate-600 mt-1">
Overzichten, statistieken en export mogelijkheden
<div className="space-y-6 p-6">
<div>
<p className="text-sm uppercase tracking-wide text-slate-500">Universele rapportage</p>
<h1 className="text-2xl font-semibold text-slate-900">Tijdlijn en notities</h1>
<p className="mt-1 text-sm text-slate-600">
Alle behandeladviezen en vrije notities voor deze cliënt op één plek.
</p>
</div>
{/* Placeholder */}
<div className="bg-white rounded-lg border border-slate-200 p-12 text-center">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-slate-50 mb-4">
<FileBarChart className="h-8 w-8 text-slate-500" />
</div>
<h3 className="text-lg font-semibold text-slate-900 mb-2">
Rapportage Module - Coming Soon
</h3>
<p className="text-sm text-slate-600 max-w-md mx-auto">
De rapportage functionaliteit wordt in een latere fase geïmplementeerd.
Dit omvat overzichten, statistieken en exportfunctionaliteit.
</p>
<div className="grid gap-4 md:grid-cols-3">
<StatCard
icon={<ClipboardList className="h-5 w-5" />}
label="Totaal rapportages"
value={totalReports.toString()}
helper={totalReports > 0 ? 'Inclusief soft-deletes' : 'Nog geen rapportages'}
/>
<StatCard
icon={<Sparkles className="h-5 w-5" />}
label="AI classificaties"
value={`${reports.filter((r) => r.ai_confidence !== null).length}`}
helper="AI helpt bij type bepaling"
/>
<StatCard
icon={<Timer className="h-5 w-5" />}
label="Laatste activiteit"
value={latestDate ?? '—'}
helper={latestDate ? 'Recentste rapportage' : 'Nog geen activiteit'}
/>
</div>
<ReportTimeline reports={reports} patientId={id} />
</div>
);
}
function StatCard({
icon,
label,
value,
helper,
}: {
icon: ReactNode;
label: string;
value: string;
helper: string;
}) {
return (
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
<div className="flex items-center justify-between text-slate-500">
<span className="text-xs font-medium uppercase tracking-wide">{label}</span>
<div className="text-slate-400">{icon}</div>
</div>
<p className="mt-3 text-2xl font-semibold text-slate-900">{value}</p>
<p className="text-xs text-slate-500">{helper}</p>
</div>
);
}

View File

@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import localFont from "next/font/local";
import "./globals.css";
import { Toaster } from '@/components/ui/toaster';
// Serif font voor long-form content (manifesto) - lokaal geladen om build zonder netwerk te laten slagen
const crimsonText = localFont({
@@ -129,6 +130,7 @@ export default function RootLayout({
className={`${crimsonText.variable} ${inter.variable} ${jetBrainsMono.variable} antialiased`}
>
{children}
<Toaster />
</body>
</html>
);

File diff suppressed because it is too large Load Diff