feat: show patient dashboard artifact on selection
This commit is contained in:
69
app/api/patients/[patientId]/dashboard/route.ts
Normal file
69
app/api/patients/[patientId]/dashboard/route.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { createClient } from '@/lib/auth/server';
|
||||||
|
import { getPatient } from '@/app/epd/patients/actions';
|
||||||
|
import { getIntakesByPatientId } from '@/app/epd/patients/[id]/intakes/actions';
|
||||||
|
import { getPatientEncounters } from '@/app/epd/agenda/actions';
|
||||||
|
import { getActiveCarePlan } from '@/app/epd/patients/[id]/behandelplan/actions';
|
||||||
|
|
||||||
|
interface RouteParams {
|
||||||
|
params: Promise<{ patientId: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractHulpvraag(notes: string | null): string | null {
|
||||||
|
if (!notes) return null;
|
||||||
|
const firstSentence = notes.split(/[.!?]/)[0];
|
||||||
|
return firstSentence.length > 150 ? `${firstSentence.slice(0, 150)}...` : firstSentence;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(_request: NextRequest, { params }: RouteParams) {
|
||||||
|
try {
|
||||||
|
const { patientId } = await params;
|
||||||
|
|
||||||
|
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: authData } = await supabase.auth.getUser();
|
||||||
|
if (!authData?.user) {
|
||||||
|
return NextResponse.json({ error: 'Niet geautoriseerd' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const patient = await getPatient(patientId);
|
||||||
|
if (!patient) {
|
||||||
|
return NextResponse.json({ error: 'Patiënt niet gevonden' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [intakes, encounters, carePlan] = await Promise.all([
|
||||||
|
getIntakesByPatientId(patientId).catch(() => []),
|
||||||
|
getPatientEncounters(patientId).catch(() => []),
|
||||||
|
getActiveCarePlan(patientId).catch(() => null),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let hulpvraag: string | null = null;
|
||||||
|
if (carePlan?.based_on_intake_id) {
|
||||||
|
const linkedIntake = intakes.find((intake) => intake.id === carePlan.based_on_intake_id);
|
||||||
|
if (linkedIntake?.notes) {
|
||||||
|
hulpvraag = extractHulpvraag(linkedIntake.notes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
patient,
|
||||||
|
intakes,
|
||||||
|
encounters,
|
||||||
|
carePlan,
|
||||||
|
hulpvraag,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Unexpected error in GET /api/patients/[patientId]/dashboard:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Onverwachte serverfout' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import { ArtifactTab } from './artifact-tab';
|
|||||||
import { DagnotatieBlock } from '../blocks/dagnotitie-block';
|
import { DagnotatieBlock } from '../blocks/dagnotitie-block';
|
||||||
import { ZoekenBlock } from '../blocks/zoeken-block';
|
import { ZoekenBlock } from '../blocks/zoeken-block';
|
||||||
import { OverdrachtBlock } from '../blocks/overdracht-block';
|
import { OverdrachtBlock } from '../blocks/overdracht-block';
|
||||||
|
import { PatientDashboardBlock } from '../blocks/patient-dashboard-block';
|
||||||
import { FallbackPicker } from '../blocks/fallback-picker';
|
import { FallbackPicker } from '../blocks/fallback-picker';
|
||||||
import type { Artifact, BlockType } from '@/stores/swift-store';
|
import type { Artifact, BlockType } from '@/stores/swift-store';
|
||||||
|
|
||||||
@@ -30,13 +31,15 @@ interface ArtifactContainerProps {
|
|||||||
function renderArtifactBlock(artifact: Artifact) {
|
function renderArtifactBlock(artifact: Artifact) {
|
||||||
switch (artifact.type) {
|
switch (artifact.type) {
|
||||||
case 'dagnotitie':
|
case 'dagnotitie':
|
||||||
return <DagnotatieBlock key={artifact.id} />;
|
return <DagnotatieBlock key={artifact.id} prefill={artifact.prefill} />;
|
||||||
case 'zoeken':
|
case 'zoeken':
|
||||||
return <ZoekenBlock key={artifact.id} />;
|
return <ZoekenBlock key={artifact.id} prefill={artifact.prefill} />;
|
||||||
case 'overdracht':
|
case 'overdracht':
|
||||||
return <OverdrachtBlock key={artifact.id} />;
|
return <OverdrachtBlock key={artifact.id} prefill={artifact.prefill} />;
|
||||||
|
case 'patient-dashboard':
|
||||||
|
return <PatientDashboardBlock key={artifact.id} prefill={artifact.prefill} />;
|
||||||
case 'fallback':
|
case 'fallback':
|
||||||
return <FallbackPicker key={artifact.id} />;
|
return <FallbackPicker key={artifact.id} originalInput={artifact.prefill?.content} />;
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<div className="p-4 text-slate-500">
|
<div className="p-4 text-slate-500">
|
||||||
@@ -61,6 +64,10 @@ export function getArtifactTitle(type: BlockType, prefill?: any): string {
|
|||||||
return 'Dienst Overdracht';
|
return 'Dienst Overdracht';
|
||||||
case 'fallback':
|
case 'fallback':
|
||||||
return 'Kies een actie';
|
return 'Kies een actie';
|
||||||
|
case 'patient-dashboard':
|
||||||
|
return prefill?.patientName
|
||||||
|
? `Dashboard - ${prefill.patientName}`
|
||||||
|
: 'Patiëntoverzicht';
|
||||||
default:
|
default:
|
||||||
return 'Artifact';
|
return 'Artifact';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,5 @@ export { DagnotatieBlock } from './dagnotitie-block';
|
|||||||
export { ZoekenBlock } from './zoeken-block';
|
export { ZoekenBlock } from './zoeken-block';
|
||||||
export { OverdrachtBlock } from './overdracht-block';
|
export { OverdrachtBlock } from './overdracht-block';
|
||||||
export { PatientContextCard } from './patient-context-card';
|
export { PatientContextCard } from './patient-context-card';
|
||||||
|
export { PatientDashboardBlock } from './patient-dashboard-block';
|
||||||
export { FallbackPicker } from './fallback-picker';
|
export { FallbackPicker } from './fallback-picker';
|
||||||
|
|||||||
369
components/swift/blocks/patient-dashboard-block.tsx
Normal file
369
components/swift/blocks/patient-dashboard-block.tsx
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Patient Dashboard Block
|
||||||
|
*
|
||||||
|
* Swift artifact that shows patient properties and dashboard summary.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { nl } from 'date-fns/locale';
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
Calendar,
|
||||||
|
ClipboardList,
|
||||||
|
Clock,
|
||||||
|
FileText,
|
||||||
|
Loader2,
|
||||||
|
User,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { BlockContainer } from './block-container';
|
||||||
|
import { safeFetch, getErrorInfo } from '@/lib/swift/error-handler';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { BLOCK_CONFIGS } from '@/lib/swift/types';
|
||||||
|
import type { BlockPrefillData } from '@/stores/swift-store';
|
||||||
|
import type { FHIRPatient } from '@/lib/fhir';
|
||||||
|
import type { Intake } from '@/lib/types/intake';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface PatientDashboardBlockProps {
|
||||||
|
prefill?: BlockPrefillData;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EncounterSummary {
|
||||||
|
id: string;
|
||||||
|
period_start: string;
|
||||||
|
period_end?: string | null;
|
||||||
|
type_display?: string | null;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CarePlanSummary {
|
||||||
|
id?: string;
|
||||||
|
title?: string | null;
|
||||||
|
status?: string | null;
|
||||||
|
based_on_intake_id?: string | null;
|
||||||
|
behandelstructuur?: unknown;
|
||||||
|
goals?: unknown;
|
||||||
|
activities?: unknown;
|
||||||
|
evaluatiemomenten?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PatientDashboardResponse {
|
||||||
|
patient: FHIRPatient;
|
||||||
|
intakes: Intake[];
|
||||||
|
encounters: EncounterSummary[];
|
||||||
|
carePlan: CarePlanSummary | null;
|
||||||
|
hulpvraag?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
planned: 'Screening',
|
||||||
|
active: 'Actief',
|
||||||
|
finished: 'Afgerond',
|
||||||
|
cancelled: 'Afgemeld',
|
||||||
|
};
|
||||||
|
|
||||||
|
const GENDER_LABELS: Record<string, string> = {
|
||||||
|
male: 'Man',
|
||||||
|
female: 'Vrouw',
|
||||||
|
other: 'Anders',
|
||||||
|
unknown: 'Onbekend',
|
||||||
|
};
|
||||||
|
|
||||||
|
function extractEpisodeStatus(patient?: FHIRPatient): string | null {
|
||||||
|
if (!patient) return null;
|
||||||
|
const statusExtension = (patient as any)?.extension?.find(
|
||||||
|
(ext: any) => ext.url === 'http://mini-epd.local/fhir/StructureDefinition/episode-status'
|
||||||
|
);
|
||||||
|
return statusExtension?.valueCode || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPatientName(patient?: FHIRPatient): string {
|
||||||
|
const name = patient?.name?.[0];
|
||||||
|
if (!name) return 'Onbekende patiënt';
|
||||||
|
return [
|
||||||
|
...(name.prefix || []),
|
||||||
|
...(name.given || []),
|
||||||
|
name.family,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPatientBsn(patient?: FHIRPatient): string | null {
|
||||||
|
if (!patient?.identifier) return null;
|
||||||
|
return (
|
||||||
|
patient.identifier.find(
|
||||||
|
(id) => id.system === 'http://fhir.nl/fhir/NamingSystem/bsn'
|
||||||
|
)?.value || null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PatientDashboardBlock({ prefill }: PatientDashboardBlockProps) {
|
||||||
|
const config = BLOCK_CONFIGS['patient-dashboard'];
|
||||||
|
const patientId = prefill?.patientId;
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [data, setData] = useState<PatientDashboardResponse | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(Boolean(patientId));
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!patientId) {
|
||||||
|
setError('Geen patiënt geselecteerd');
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchDashboard = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await safeFetch(
|
||||||
|
`/api/patients/${patientId}/dashboard`,
|
||||||
|
undefined,
|
||||||
|
{ operation: 'Patiëntdashboard laden' }
|
||||||
|
);
|
||||||
|
const result = (await response.json()) as PatientDashboardResponse;
|
||||||
|
setData(result);
|
||||||
|
} catch (err) {
|
||||||
|
const statusCode = (err as any)?.statusCode;
|
||||||
|
const errorInfo = getErrorInfo(err, {
|
||||||
|
operation: 'Patiëntdashboard laden',
|
||||||
|
statusCode,
|
||||||
|
});
|
||||||
|
setError(errorInfo.description);
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: errorInfo.title,
|
||||||
|
description: errorInfo.description,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchDashboard();
|
||||||
|
}, [patientId, toast]);
|
||||||
|
|
||||||
|
const patient = data?.patient;
|
||||||
|
const patientName = useMemo(() => getPatientName(patient), [patient]);
|
||||||
|
const patientStatus = extractEpisodeStatus(patient);
|
||||||
|
const patientStatusLabel = patientStatus ? STATUS_LABELS[patientStatus] || patientStatus : null;
|
||||||
|
const patientBirthDate = patient?.birthDate
|
||||||
|
? format(new Date(patient.birthDate), 'd MMM yyyy', { locale: nl })
|
||||||
|
: 'Onbekend';
|
||||||
|
const patientGender = patient?.gender ? GENDER_LABELS[patient.gender] || patient.gender : 'Onbekend';
|
||||||
|
const patientBsn = getPatientBsn(patient) || 'Onbekend';
|
||||||
|
|
||||||
|
const recentIntakes = data?.intakes?.slice(0, 3) || [];
|
||||||
|
const encounters = data?.encounters || [];
|
||||||
|
|
||||||
|
const encounterGroups = useMemo(() => {
|
||||||
|
const now = new Date();
|
||||||
|
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
|
||||||
|
const upcoming = encounters.filter((e) => new Date(e.period_start) >= todayStart);
|
||||||
|
const recent = encounters.filter((e) => new Date(e.period_start) < todayStart);
|
||||||
|
|
||||||
|
const displayEncounters = [...upcoming, ...recent].slice(0, 5);
|
||||||
|
return displayEncounters;
|
||||||
|
}, [encounters]);
|
||||||
|
|
||||||
|
const goalsCount = Array.isArray(data?.carePlan?.goals) ? data?.carePlan?.goals.length : 0;
|
||||||
|
const interventionsCount = Array.isArray(data?.carePlan?.activities)
|
||||||
|
? data?.carePlan?.activities.length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const title = prefill?.patientName
|
||||||
|
? `${config.title} - ${prefill.patientName}`
|
||||||
|
: config.title;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BlockContainer title={title} size={config.size}>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-slate-400 mr-2" />
|
||||||
|
<span className="text-sm text-slate-500">Dashboard laden...</span>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<AlertCircle className="h-8 w-8 text-red-500 mx-auto mb-2" />
|
||||||
|
<p className="text-sm text-red-700">{error}</p>
|
||||||
|
</div>
|
||||||
|
) : data ? (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Basisgegevens */}
|
||||||
|
<section className="bg-white rounded-lg border border-slate-200 p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<User className="h-4 w-4 text-blue-600" />
|
||||||
|
<h3 className="text-sm font-medium text-slate-700">Basisgegevens</h3>
|
||||||
|
{patientStatusLabel && (
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-full bg-slate-100 text-slate-700 border border-slate-200">
|
||||||
|
{patientStatusLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500">Naam</p>
|
||||||
|
<p className="text-slate-900 font-medium">{patientName}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500">Geboortedatum</p>
|
||||||
|
<p className="text-slate-900">{patientBirthDate}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500">BSN</p>
|
||||||
|
<p className="text-slate-900">{patientBsn}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500">Geslacht</p>
|
||||||
|
<p className="text-slate-900">{patientGender}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Recente intakes */}
|
||||||
|
<section className="bg-white rounded-lg border border-slate-200 p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<FileText className="h-4 w-4 text-teal-600" />
|
||||||
|
<h3 className="text-sm font-medium text-slate-700">Recente intakes</h3>
|
||||||
|
<span className="text-xs text-slate-500">({data.intakes.length})</span>
|
||||||
|
</div>
|
||||||
|
{recentIntakes.length === 0 ? (
|
||||||
|
<p className="text-sm text-slate-500">Geen intakes gevonden</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{recentIntakes.map((intake) => (
|
||||||
|
<div
|
||||||
|
key={intake.id}
|
||||||
|
className="flex items-center justify-between p-3 rounded-lg bg-slate-50 border border-slate-200"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-900">{intake.title}</p>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-slate-500 mt-0.5">
|
||||||
|
<span>{intake.department}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>
|
||||||
|
{format(new Date(intake.start_date), 'd MMM yyyy', { locale: nl })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className={cn(
|
||||||
|
'px-2 py-0.5 rounded-full text-xs font-medium',
|
||||||
|
intake.status === 'Open'
|
||||||
|
? 'bg-blue-50 text-blue-700'
|
||||||
|
: 'bg-green-50 text-green-700'
|
||||||
|
)}>
|
||||||
|
{intake.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Agenda afspraken */}
|
||||||
|
<section className="bg-white rounded-lg border border-slate-200 p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Calendar className="h-4 w-4 text-amber-600" />
|
||||||
|
<h3 className="text-sm font-medium text-slate-700">Agenda afspraken</h3>
|
||||||
|
<span className="text-xs text-slate-500">({encounters.length})</span>
|
||||||
|
</div>
|
||||||
|
{encounterGroups.length === 0 ? (
|
||||||
|
<p className="text-sm text-slate-500">Geen afspraken gevonden</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{encounterGroups.map((encounter) => {
|
||||||
|
const encounterDate = new Date(encounter.period_start);
|
||||||
|
const isPast = encounterDate < new Date();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={encounter.id}
|
||||||
|
className="flex items-center justify-between p-3 rounded-lg bg-slate-50 border border-slate-200"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-900">
|
||||||
|
{encounter.type_display || 'Afspraak'}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-slate-500 mt-0.5">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
<span>
|
||||||
|
{format(encounterDate, 'd MMM yyyy HH:mm', { locale: nl })}
|
||||||
|
</span>
|
||||||
|
{encounter.period_end && (
|
||||||
|
<>
|
||||||
|
<span>•</span>
|
||||||
|
<span>
|
||||||
|
{format(new Date(encounter.period_end), 'HH:mm', { locale: nl })}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className={cn(
|
||||||
|
'px-2 py-0.5 rounded-full text-xs font-medium',
|
||||||
|
encounter.status === 'planned' || encounter.status === 'arrived'
|
||||||
|
? 'bg-blue-50 text-blue-700'
|
||||||
|
: encounter.status === 'finished'
|
||||||
|
? 'bg-green-50 text-green-700'
|
||||||
|
: isPast
|
||||||
|
? 'bg-slate-100 text-slate-700'
|
||||||
|
: 'bg-slate-50 text-slate-700'
|
||||||
|
)}>
|
||||||
|
{encounter.status === 'planned'
|
||||||
|
? 'Gepland'
|
||||||
|
: encounter.status === 'arrived'
|
||||||
|
? 'Aangekomen'
|
||||||
|
: encounter.status === 'finished'
|
||||||
|
? 'Afgerond'
|
||||||
|
: encounter.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Behandelplan */}
|
||||||
|
<section className="bg-white rounded-lg border border-slate-200 p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<ClipboardList className="h-4 w-4 text-purple-600" />
|
||||||
|
<h3 className="text-sm font-medium text-slate-700">Actief behandelplan</h3>
|
||||||
|
</div>
|
||||||
|
{data.carePlan ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{data.hulpvraag && (
|
||||||
|
<div className="bg-slate-50 rounded-lg p-3 text-sm text-slate-700">
|
||||||
|
<p className="text-xs font-medium text-slate-500 mb-1">Hulpvraag</p>
|
||||||
|
<p className="italic">“{data.hulpvraag}”</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div className="bg-teal-50 border border-teal-200 rounded-lg p-3">
|
||||||
|
<p className="text-xs text-teal-700 mb-1">Doelen</p>
|
||||||
|
<p className="text-base font-semibold text-teal-900">{goalsCount}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-purple-50 border border-purple-200 rounded-lg p-3">
|
||||||
|
<p className="text-xs text-purple-700 mb-1">Interventies</p>
|
||||||
|
<p className="text-base font-semibold text-purple-900">{interventionsCount}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-slate-500">Geen actief behandelplan</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-slate-500">Geen gegevens beschikbaar</div>
|
||||||
|
)}
|
||||||
|
</BlockContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -34,11 +34,12 @@ interface PatientSearchResult {
|
|||||||
|
|
||||||
export function ZoekenBlock({ prefill }: ZoekenBlockProps) {
|
export function ZoekenBlock({ prefill }: ZoekenBlockProps) {
|
||||||
const config = BLOCK_CONFIGS.zoeken;
|
const config = BLOCK_CONFIGS.zoeken;
|
||||||
const { closeBlock, setActivePatient, addRecentAction } = useSwiftStore();
|
const { closeBlock, setActivePatient, addRecentAction, openArtifact } = useSwiftStore();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const prefillQuery = prefill?.patientName || prefill?.query || '';
|
||||||
|
|
||||||
// Search state
|
// Search state
|
||||||
const [searchQuery, setSearchQuery] = useState<string>(prefill?.patientName || '');
|
const [searchQuery, setSearchQuery] = useState<string>(prefillQuery);
|
||||||
const [patients, setPatients] = useState<PatientSearchResult[]>([]);
|
const [patients, setPatients] = useState<PatientSearchResult[]>([]);
|
||||||
const [isSearching, setIsSearching] = useState(false);
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
const [selectedPatientId, setSelectedPatientId] = useState<string | null>(null);
|
const [selectedPatientId, setSelectedPatientId] = useState<string | null>(null);
|
||||||
@@ -81,14 +82,14 @@ export function ZoekenBlock({ prefill }: ZoekenBlockProps) {
|
|||||||
|
|
||||||
// Prefill search query
|
// Prefill search query
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (prefill?.patientName) {
|
if (prefillQuery) {
|
||||||
setSearchQuery(prefill.patientName);
|
setSearchQuery(prefillQuery);
|
||||||
// Auto-search if prefill is provided
|
// Auto-search if prefill is provided
|
||||||
if (prefill.patientName.length >= 2) {
|
if (prefillQuery.length >= 2) {
|
||||||
searchPatients(prefill.patientName);
|
searchPatients(prefillQuery);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [prefill, searchPatients]);
|
}, [prefillQuery, searchPatients]);
|
||||||
|
|
||||||
// Debounced search
|
// Debounced search
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -201,14 +202,16 @@ export function ZoekenBlock({ prefill }: ZoekenBlockProps) {
|
|||||||
description: `${patient.name} is nu actief`,
|
description: `${patient.name} is nu actief`,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Close search block and open PatientContextCard
|
// Close legacy block (v2) and open dashboard artifact (v3)
|
||||||
closeBlock();
|
closeBlock();
|
||||||
|
openArtifact({
|
||||||
// Open PatientContextCard after a short delay to allow ZoekenBlock to close
|
type: 'patient-dashboard',
|
||||||
setTimeout(() => {
|
title: `Dashboard - ${patient.name}`,
|
||||||
// PatientContextCard will auto-open when activePatient is set
|
prefill: {
|
||||||
// We don't need to explicitly open it as a block - it's shown automatically
|
patientId: patient.id,
|
||||||
}, 100);
|
patientName: patient.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to select patient:', error);
|
console.error('Failed to select patient:', error);
|
||||||
const statusCode = (error as any)?.statusCode;
|
const statusCode = (error as any)?.statusCode;
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ Development practices:
|
|||||||
| E0 | Product/UX alignment | Scope en UX flows vastleggen | Done | 2 | Route group akkoord |
|
| E0 | Product/UX alignment | Scope en UX flows vastleggen | Done | 2 | Route group akkoord |
|
||||||
| E1 | Routing & layout scheiding | Swift los van EPD layout | Done | 4 | `/epd/swift` blijft |
|
| E1 | Routing & layout scheiding | Swift los van EPD layout | Done | 4 | `/epd/swift` blijft |
|
||||||
| E2 | Navigatie & toegang | Entry/exit flows borgen | Done | 3 | MVP only |
|
| E2 | Navigatie & toegang | Entry/exit flows borgen | Done | 3 | MVP only |
|
||||||
| E3 | Swift shell polish | Full-screen gedrag + responsive | To Do | 3 | Geen redesign |
|
| E3 | Swift shell polish | Full-screen gedrag + responsive | Done | 3 | Geen redesign |
|
||||||
| E4 | QA & docs | Validatie en documentatie | To Do | 3 | Manual checks |
|
| E4 | QA & docs | Validatie en documentatie | To Do | 3 | Manual checks |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -114,9 +114,9 @@ Epic doel: full-screen ervaring is clean en consistent.
|
|||||||
|
|
||||||
| Story ID | Beschrijving | Acceptatiecriteria | Status | Afhankelijkheden | Story Points |
|
| Story ID | Beschrijving | Acceptatiecriteria | Status | Afhankelijkheden | Story Points |
|
||||||
|----------|--------------|---------------------|--------|------------------|--------------|
|
|----------|--------------|---------------------|--------|------------------|--------------|
|
||||||
| E3.S1 | Full-screen layout check | Geen sidebar spacing of EPD padding zichtbaar | To Do | E1.S2 | 2 |
|
| E3.S1 | Full-screen layout check | Geen sidebar spacing of EPD padding zichtbaar | Done | E1.S2 | 2 |
|
||||||
| E3.S2 | Responsive gedrag check | Chat/artifacts stacken op mobiel zoals nu | To Do | E1.S2 | 2 |
|
| E3.S2 | Responsive gedrag check | Chat/artifacts stacken op mobiel zoals nu | Done | E1.S2 | 2 |
|
||||||
| E3.S3 | Visuele consistentie | Context bar en offline banner correct gepositioneerd | To Do | E1.S2 | 1 |
|
| E3.S3 | Visuele consistentie | Context bar en offline banner correct gepositioneerd | Done | E1.S2 | 1 |
|
||||||
|
|
||||||
Technical notes:
|
Technical notes:
|
||||||
- Geen redesign of nieuwe UI componenten.
|
- Geen redesign of nieuwe UI componenten.
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const ActionSchema = z.object({
|
|||||||
confidence: z.number().min(0).max(1),
|
confidence: z.number().min(0).max(1),
|
||||||
artifact: z
|
artifact: z
|
||||||
.object({
|
.object({
|
||||||
type: z.enum(['dagnotitie', 'zoeken', 'overdracht', 'fallback']),
|
type: z.enum(['dagnotitie', 'zoeken', 'overdracht', 'fallback', 'patient-dashboard']),
|
||||||
prefill: z.record(z.string(), z.any()),
|
prefill: z.record(z.string(), z.any()),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
@@ -140,6 +140,7 @@ export function getConfidenceLabel(confidence: number): string {
|
|||||||
*/
|
*/
|
||||||
export function validateArtifactType(intent: SwiftIntent, artifactType?: BlockType): boolean {
|
export function validateArtifactType(intent: SwiftIntent, artifactType?: BlockType): boolean {
|
||||||
if (!artifactType) return true; // No artifact is valid
|
if (!artifactType) return true; // No artifact is valid
|
||||||
|
if (artifactType === 'patient-dashboard') return true;
|
||||||
|
|
||||||
// Intent should match artifact type (except for 'unknown' and 'fallback')
|
// Intent should match artifact type (except for 'unknown' and 'fallback')
|
||||||
if (intent === 'unknown') return artifactType === 'fallback';
|
if (intent === 'unknown') return artifactType === 'fallback';
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import type { VerpleegkundigCategory } from '@/lib/types/report';
|
|||||||
// Intent types
|
// Intent types
|
||||||
export type SwiftIntent = 'dagnotitie' | 'zoeken' | 'overdracht' | 'unknown';
|
export type SwiftIntent = 'dagnotitie' | 'zoeken' | 'overdracht' | 'unknown';
|
||||||
|
|
||||||
export type BlockType = Exclude<SwiftIntent, 'unknown'>;
|
export type BlockType = Exclude<SwiftIntent, 'unknown'> | 'patient-dashboard';
|
||||||
|
|
||||||
// Shift types
|
// Shift types
|
||||||
export type ShiftType = 'nacht' | 'ochtend' | 'middag' | 'avond';
|
export type ShiftType = 'nacht' | 'ochtend' | 'middag' | 'avond';
|
||||||
@@ -28,6 +28,7 @@ export interface ExtractedEntities {
|
|||||||
patientId?: string;
|
patientId?: string;
|
||||||
category?: VerpleegkundigCategory;
|
category?: VerpleegkundigCategory;
|
||||||
content?: string;
|
content?: string;
|
||||||
|
query?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Block sizes
|
// Block sizes
|
||||||
@@ -61,6 +62,12 @@ export const BLOCK_CONFIGS: Record<BlockType, BlockConfig> = {
|
|||||||
size: 'lg',
|
size: 'lg',
|
||||||
icon: 'ArrowRightLeft',
|
icon: 'ArrowRightLeft',
|
||||||
},
|
},
|
||||||
|
'patient-dashboard': {
|
||||||
|
type: 'patient-dashboard',
|
||||||
|
title: 'Patiëntoverzicht',
|
||||||
|
size: 'lg',
|
||||||
|
icon: 'LayoutDashboard',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Recent action type
|
// Recent action type
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export type SwiftIntent =
|
|||||||
| 'overdracht'
|
| 'overdracht'
|
||||||
| 'unknown';
|
| 'unknown';
|
||||||
|
|
||||||
export type BlockType = Exclude<SwiftIntent, 'unknown'> | 'fallback';
|
export type BlockType = Exclude<SwiftIntent, 'unknown'> | 'fallback' | 'patient-dashboard';
|
||||||
|
|
||||||
// Chat types (v3.0)
|
// Chat types (v3.0)
|
||||||
export type ChatMessageType = 'user' | 'assistant' | 'system' | 'error';
|
export type ChatMessageType = 'user' | 'assistant' | 'system' | 'error';
|
||||||
@@ -44,6 +44,7 @@ export interface ExtractedEntities {
|
|||||||
patientId?: string;
|
patientId?: string;
|
||||||
category?: VerpleegkundigCategory;
|
category?: VerpleegkundigCategory;
|
||||||
content?: string;
|
content?: string;
|
||||||
|
query?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Block prefill data
|
// Block prefill data
|
||||||
|
|||||||
Reference in New Issue
Block a user