feat(swift): implementeer linked evidence UI voor overdracht (E5.S2)
E5.S2 compleet: Bronnotitie links met hover preview in OverdrachtBlock. Voortgang: 66 SP / 82 SP (80%) - Epic 5 40% compleet! Implementatie: - Bronverwijzingen nu klikbaar met hover popover voor volledige content - Source data enrichment in API response - Type-specific content display (observaties, rapportages, risico's) - Visual feedback met icons en kleuren per bron type Components & Types: - LinkedEvidence component (160 regels) - Reusable popover voor alle bron types - Aandachtspunt type extended met optionele sourceData field - enrichWithSourceData() functie in API route (48 regels) LinkedEvidence Features: - Hover popover met volledige bron content - Type-specific icons: Activity (observatie), FileText (rapportage), AlertTriangle (risico) - Click/hover trigger met underline-dotted styling - ExternalLink icon voor visual affordance - Popover positioning: top-start voor beste UX Source Data per Type: - Observaties: value + unit + interpretation (HH/H/N/L/LL) met kleuren - Rapportages/Verpleegkundig: content + createdBy - Risico's: riskLevel + rationale met severity kleuren API Enrichment Logic: - enrichWithSourceData() lookup in context (vitals, reports, risks) - Source data toegevoegd aan aandachtspunten vóór response - Type-safe matching op bron.id en bron.type - Backwards compatible: werkt met/zonder sourceData UI/UX Improvements: - Color-coded interpretations: Red (HH/LL kritiek), Amber (H/L), Teal (N) - Color-coded risk levels: Red (zeer hoog/hoog), Amber (gemiddeld), Teal (laag) - Formatted text display met bg-slate-50 border - Responsive popover met max-width 96 (384px) - Fallback: geen sourceData = plain text zonder popover Integration in OverdrachtBlock: - AandachtspuntItem gebruikt LinkedEvidence component - Replaced: "bron.label • bron.datum" plain text - Now: LinkedEvidence met hover preview Files Changed: - lib/types/overdracht.ts: +12 lines (sourceData interface) - app/api/overdracht/generate/route.ts: +58 lines (enrichWithSourceData) - components/swift/blocks/overdracht-block.tsx: +1/-3 lines (LinkedEvidence import/usage) - components/swift/shared/linked-evidence.tsx: +160 lines (NEW) Technical Details: - Popover from shadcn/ui (@/components/ui/popover) - Icons from lucide-react (Activity, FileText, AlertTriangle, ExternalLink) - Type guards voor safe property access (sourceData?.value) - Underline decoration-dotted cursor-help voor accessibility Testing: - Build succesvol (pnpm build) - /epd/swift: 143 kB (+24 kB door LinkedEvidence component) - Geen nieuwe type errors - Popover trigger werkt met keyboard (accessible) Voortgang: 66 SP / 82 SP (80%) - E5.S2 compleet (3 SP) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -219,6 +219,59 @@ async function callClaudeAPI(
|
|||||||
return validated;
|
return validated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enrich aandachtspunten with full source data
|
||||||
|
*/
|
||||||
|
function enrichWithSourceData(
|
||||||
|
aandachtspunten: Aandachtspunt[],
|
||||||
|
context: OverdrachtContext
|
||||||
|
): Aandachtspunt[] {
|
||||||
|
return aandachtspunten.map((punt) => {
|
||||||
|
const { bron } = punt;
|
||||||
|
let sourceData: Aandachtspunt['sourceData'];
|
||||||
|
|
||||||
|
switch (bron.type) {
|
||||||
|
case 'observatie': {
|
||||||
|
const vital = context.vitals.find((v) => v.id === bron.id);
|
||||||
|
if (vital) {
|
||||||
|
sourceData = {
|
||||||
|
value: vital.value_quantity_value?.toString() || undefined,
|
||||||
|
unit: vital.value_quantity_unit || undefined,
|
||||||
|
interpretation: vital.interpretation_code || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'rapportage':
|
||||||
|
case 'verpleegkundig': {
|
||||||
|
const report = context.reports.find((r) => r.id === bron.id);
|
||||||
|
if (report) {
|
||||||
|
sourceData = {
|
||||||
|
content: report.content,
|
||||||
|
createdBy: report.created_by || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'risico': {
|
||||||
|
const risk = context.risks.find((r) => r.id === bron.id);
|
||||||
|
if (risk) {
|
||||||
|
sourceData = {
|
||||||
|
riskLevel: risk.risk_level,
|
||||||
|
rationale: risk.rationale || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...punt,
|
||||||
|
sourceData,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Log AI event to database
|
* Log AI event to database
|
||||||
*/
|
*/
|
||||||
@@ -298,13 +351,16 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const durationMs = Date.now() - startTime;
|
const durationMs = Date.now() - startTime;
|
||||||
|
|
||||||
|
// Enrich aandachtspunten with full source data for linked evidence
|
||||||
|
const enrichedAandachtspunten = enrichWithSourceData(aiResult.aandachtspunten, context);
|
||||||
|
|
||||||
// Log AI event
|
// Log AI event
|
||||||
await logAIEvent(supabase, patientId, context, aiResult, durationMs);
|
await logAIEvent(supabase, patientId, context, aiResult, durationMs);
|
||||||
|
|
||||||
// Build response
|
// Build response
|
||||||
const response: AISamenvatting = {
|
const response: AISamenvatting = {
|
||||||
samenvatting: aiResult.samenvatting,
|
samenvatting: aiResult.samenvatting,
|
||||||
aandachtspunten: aiResult.aandachtspunten,
|
aandachtspunten: enrichedAandachtspunten,
|
||||||
actiepunten: aiResult.actiepunten,
|
actiepunten: aiResult.actiepunten,
|
||||||
generatedAt: new Date().toISOString(),
|
generatedAt: new Date().toISOString(),
|
||||||
durationMs,
|
durationMs,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { nl } from 'date-fns/locale/nl';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { safeFetch, getErrorInfo, retryFetch } from '@/lib/swift/error-handler';
|
import { safeFetch, getErrorInfo, retryFetch } from '@/lib/swift/error-handler';
|
||||||
|
import { LinkedEvidence } from '@/components/swift/shared/linked-evidence';
|
||||||
|
|
||||||
interface OverdrachtBlockProps {
|
interface OverdrachtBlockProps {
|
||||||
prefill?: BlockPrefillData;
|
prefill?: BlockPrefillData;
|
||||||
@@ -487,9 +488,7 @@ function AandachtspuntItem({ punt }: { punt: AISamenvatting['aandachtspunten'][0
|
|||||||
>
|
>
|
||||||
{getBronTypeLabel(punt.bron.type)}
|
{getBronTypeLabel(punt.bron.type)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-slate-500">
|
<LinkedEvidence bron={punt.bron} sourceData={punt.sourceData} />
|
||||||
{punt.bron.label} • {punt.bron.datum}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
160
components/swift/shared/linked-evidence.tsx
Normal file
160
components/swift/shared/linked-evidence.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Linked Evidence Component
|
||||||
|
*
|
||||||
|
* Shows source data in a hover popover for overdracht aandachtspunten.
|
||||||
|
* E5.S2: Provides quick access to original source without leaving context.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
|
import type { Aandachtspunt } from '@/lib/types/overdracht';
|
||||||
|
import { ExternalLink, Activity, FileText, AlertTriangle } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface LinkedEvidenceProps {
|
||||||
|
bron: Aandachtspunt['bron'];
|
||||||
|
sourceData?: Aandachtspunt['sourceData'];
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LinkedEvidence({ bron, sourceData, className }: LinkedEvidenceProps) {
|
||||||
|
// If no source data, just show label without popover
|
||||||
|
if (!sourceData) {
|
||||||
|
return (
|
||||||
|
<span className={cn('inline-flex items-center gap-1 text-xs text-slate-500', className)}>
|
||||||
|
{bron.label} • {bron.datum}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getBronIcon = () => {
|
||||||
|
switch (bron.type) {
|
||||||
|
case 'observatie':
|
||||||
|
return <Activity className="h-3.5 w-3.5" />;
|
||||||
|
case 'rapportage':
|
||||||
|
case 'verpleegkundig':
|
||||||
|
return <FileText className="h-3.5 w-3.5" />;
|
||||||
|
case 'risico':
|
||||||
|
return <AlertTriangle className="h-3.5 w-3.5" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getInterpretationColor = (code: string | undefined) => {
|
||||||
|
if (!code) return 'text-slate-600';
|
||||||
|
switch (code) {
|
||||||
|
case 'HH':
|
||||||
|
case 'LL':
|
||||||
|
return 'text-red-700 font-semibold';
|
||||||
|
case 'H':
|
||||||
|
case 'L':
|
||||||
|
return 'text-amber-700 font-medium';
|
||||||
|
case 'N':
|
||||||
|
return 'text-teal-700';
|
||||||
|
default:
|
||||||
|
return 'text-slate-600';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRiskLevelColor = (level: string | undefined) => {
|
||||||
|
if (!level) return 'text-slate-600';
|
||||||
|
switch (level.toLowerCase()) {
|
||||||
|
case 'zeer_hoog':
|
||||||
|
case 'hoog':
|
||||||
|
return 'text-red-700 font-semibold';
|
||||||
|
case 'gemiddeld':
|
||||||
|
return 'text-amber-700';
|
||||||
|
case 'laag':
|
||||||
|
return 'text-teal-700';
|
||||||
|
default:
|
||||||
|
return 'text-slate-600';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1 text-xs text-slate-600 hover:text-slate-900',
|
||||||
|
'underline decoration-dotted underline-offset-2 cursor-help transition-colors',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{getBronIcon()}
|
||||||
|
<span>{bron.label}</span>
|
||||||
|
<ExternalLink className="h-3 w-3 opacity-50" />
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-96 p-4" side="top" align="start">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="border-b border-slate-200 pb-2">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{getBronIcon()}
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-slate-900">{bron.label}</div>
|
||||||
|
<div className="text-xs text-slate-500">{bron.datum}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content based on type */}
|
||||||
|
{bron.type === 'observatie' && sourceData.value && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span className="text-2xl font-semibold text-slate-900">
|
||||||
|
{sourceData.value}
|
||||||
|
</span>
|
||||||
|
{sourceData.unit && (
|
||||||
|
<span className="text-sm text-slate-600">{sourceData.unit}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{sourceData.interpretation && (
|
||||||
|
<div className={cn('text-sm', getInterpretationColor(sourceData.interpretation))}>
|
||||||
|
Interpretatie: {sourceData.interpretation}
|
||||||
|
{sourceData.interpretation === 'HH' && ' (Kritiek hoog)'}
|
||||||
|
{sourceData.interpretation === 'H' && ' (Hoog)'}
|
||||||
|
{sourceData.interpretation === 'LL' && ' (Kritiek laag)'}
|
||||||
|
{sourceData.interpretation === 'L' && ' (Laag)'}
|
||||||
|
{sourceData.interpretation === 'N' && ' (Normaal)'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(bron.type === 'rapportage' || bron.type === 'verpleegkundig') && sourceData.content && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-sm text-slate-700 bg-slate-50 rounded p-3 border border-slate-200">
|
||||||
|
{sourceData.content}
|
||||||
|
</div>
|
||||||
|
{sourceData.createdBy && (
|
||||||
|
<div className="text-xs text-slate-500">
|
||||||
|
Door: {sourceData.createdBy}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{bron.type === 'risico' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{sourceData.riskLevel && (
|
||||||
|
<div className={cn('text-sm font-medium', getRiskLevelColor(sourceData.riskLevel))}>
|
||||||
|
Risiconiveau: {sourceData.riskLevel.replace('_', ' ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sourceData.rationale && (
|
||||||
|
<div className="text-sm text-slate-700 bg-slate-50 rounded p-3 border border-slate-200">
|
||||||
|
{sourceData.rationale}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -96,6 +96,18 @@ export interface Aandachtspunt {
|
|||||||
datum: string;
|
datum: string;
|
||||||
label: string;
|
label: string;
|
||||||
};
|
};
|
||||||
|
sourceData?: {
|
||||||
|
// Voor observaties (vitals)
|
||||||
|
value?: string;
|
||||||
|
unit?: string;
|
||||||
|
interpretation?: string;
|
||||||
|
// Voor reports (rapportage, verpleegkundig)
|
||||||
|
content?: string;
|
||||||
|
createdBy?: string;
|
||||||
|
// Voor risico's
|
||||||
|
riskLevel?: string;
|
||||||
|
rationale?: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zod schema for generate request
|
// Zod schema for generate request
|
||||||
|
|||||||
Reference in New Issue
Block a user