feat(ui): add premium InsightAdvisorCard and InsightStatusCard components

New insight card components for the health advisor feature:
- InsightAdvisorCard: full structured display with primary recommendation,
  findings, correlations, recommendations, and collapsible data quality
- InsightStatusCard: compact per-metric status card with classification indicator
- Subtle fade-in animation for insight cards in globals.css

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.6 committed 2026-04-06 13:30:53 +02:00
1 parent c730845775
commit eaca8674f5
3 files changed
+533

No files matched your search

+16
View File
@@ -166,3 +166,19 @@
color: var(--foreground);
}
}
/* ── Insight card animations ─────────────────────── */
@keyframes insight-fade-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-insight-in {
animation: insight-fade-in 0.4s ease-out forwards;
}
@@ -0,0 +1,398 @@
"use client";
import { useState } from "react";
import type { InsightResult, InsightFinding } from "@/lib/ai/types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Sparkles,
RefreshCw,
Loader2,
CheckCircle2,
Minus,
AlertCircle,
AlertTriangle,
ChevronDown,
ChevronUp,
Info,
} from "lucide-react";
// ─── Types ────────────────────────────────────────────────
interface InsightAdvisorCardProps {
title: string;
icon?: React.ReactNode;
insight: InsightResult | null;
loading?: boolean;
error?: string | null;
onRegenerate?: () => void;
regenerating?: boolean;
cachedAt?: string | null;
}
// ─── Classification Colors ───────────────────────────────
const CLASSIFICATION_STYLES: Record<
string,
{ badge: string; border: string }
> = {
optimal: {
badge:
"bg-[#50fa7b]/10 text-[#50fa7b] border border-[#50fa7b]/25 hover:bg-[#50fa7b]/15",
border: "border-l-[#50fa7b]",
},
gut: {
badge:
"bg-[#8be9fd]/10 text-[#8be9fd] border border-[#8be9fd]/25 hover:bg-[#8be9fd]/15",
border: "border-l-[#8be9fd]",
},
grenzwertig: {
badge:
"bg-[#f1fa8c]/10 text-[#f1fa8c] border border-[#f1fa8c]/25 hover:bg-[#f1fa8c]/15",
border: "border-l-[#f1fa8c]",
},
erhoht: {
badge:
"bg-[#ffb86c]/10 text-[#ffb86c] border border-[#ffb86c]/25 hover:bg-[#ffb86c]/15",
border: "border-l-[#ffb86c]",
},
kritisch: {
badge:
"bg-[#ff5555]/10 text-[#ff5555] border border-[#ff5555]/25 hover:bg-[#ff5555]/15",
border: "border-l-[#ff5555]",
},
};
// ─── Assessment Icons ─────────────────────────────────────
function AssessmentIcon({
assessment,
}: {
assessment: InsightFinding["assessment"];
}) {
switch (assessment) {
case "positive":
return <CheckCircle2 className="h-4 w-4 shrink-0 text-[#50fa7b]" />;
case "neutral":
return <Minus className="h-4 w-4 shrink-0 text-[#9aa3b3]" />;
case "attention":
return <AlertCircle className="h-4 w-4 shrink-0 text-[#ffb86c]" />;
case "warning":
return <AlertTriangle className="h-4 w-4 shrink-0 text-[#ff5555]" />;
}
}
// ─── Confidence Colors ────────────────────────────────────
const CONFIDENCE_STYLES: Record<string, string> = {
hoch: "text-[#50fa7b]",
mittel: "text-[#f1fa8c]",
gering: "text-[#ffb86c]",
};
// ─── Section Separator ────────────────────────────────────
function SectionSeparator({ label }: { label: string }) {
return (
<div className="flex items-center gap-2 text-xs uppercase tracking-wider font-medium text-muted-foreground">
<span>{label}</span>
<div className="h-px flex-1 bg-border" />
</div>
);
}
// ─── Main Component ───────────────────────────────────────
export function InsightAdvisorCard({
title,
icon,
insight,
loading = false,
error = null,
onRegenerate,
regenerating = false,
cachedAt,
}: InsightAdvisorCardProps) {
const [dataQualityOpen, setDataQualityOpen] = useState(false);
const classStyle = insight
? CLASSIFICATION_STYLES[insight.classification] ??
CLASSIFICATION_STYLES.gut
: CLASSIFICATION_STYLES.gut;
// ── Loading State ─────────────────────────────────────
if (loading && !insight) {
return (
<Card>
<CardContent className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-[#bd93f9]" />
<span className="ml-2 text-sm text-muted-foreground">
{/* TODO: i18n */}
Analyse wird erstellt...
</span>
</CardContent>
</Card>
);
}
// ── Empty / No Data State ─────────────────────────────
if (!insight) {
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{icon ?? (
<Sparkles className="h-5 w-5 text-[#bd93f9]" />
)}
{/* TODO: i18n */}
<CardTitle className="text-lg">KI-Gesundheitsanalyse</CardTitle>
</div>
</div>
<p className="text-sm text-muted-foreground">{title}</p>
</CardHeader>
<CardContent>
{error && (
<div className="flex items-center gap-2 rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
<AlertTriangle className="h-4 w-4 shrink-0" />
{error}
</div>
)}
{!error && (
<div className="flex flex-col items-center gap-3 py-6">
<p className="text-sm text-muted-foreground">
{/* TODO: i18n */}
Noch keine Analyse vorhanden.
</p>
{onRegenerate && (
<Button
variant="outline"
size="sm"
onClick={onRegenerate}
disabled={regenerating}
>
{regenerating ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Sparkles className="mr-1.5 h-3.5 w-3.5" />
)}
{/* TODO: i18n */}
Analyse starten
</Button>
)}
</div>
)}
</CardContent>
</Card>
);
}
// ── Full Insight Card ─────────────────────────────────
return (
<Card className="animate-insight-in">
{/* Header */}
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{icon ?? (
<Sparkles className="h-5 w-5 text-[#bd93f9]" />
)}
{/* TODO: i18n */}
<CardTitle className="text-lg">KI-Gesundheitsanalyse</CardTitle>
</div>
<div className="flex items-center gap-2">
<Badge className={classStyle.badge}>
{insight.classificationLabel ?? insight.classification}
</Badge>
{onRegenerate && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onRegenerate}
disabled={regenerating}
title="Analyse aktualisieren" // TODO: i18n
>
{regenerating ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
</Button>
)}
</div>
</div>
<p className="text-sm text-muted-foreground">{title}</p>
</CardHeader>
<CardContent className="space-y-5">
{/* Error banner */}
{error && (
<div className="flex items-center gap-2 rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
<AlertTriangle className="h-4 w-4 shrink-0" />
{error}
</div>
)}
{/* Primary Recommendation */}
{insight.primaryRecommendation && (
<div className="rounded-md border-l-2 border-[#bd93f9] bg-[#bd93f9]/5 px-4 py-3">
<p className="mb-1 text-xs font-medium uppercase tracking-widest text-[#bd93f9]">
{/* TODO: i18n */}
Das Wichtigste
</p>
<p className="text-sm">{insight.primaryRecommendation}</p>
</div>
)}
{/* Summary */}
<p className="text-sm leading-relaxed text-muted-foreground">
{insight.summary}
</p>
{/* Findings */}
{insight.findings.length > 0 && (
<div className="space-y-3">
{/* TODO: i18n */}
<SectionSeparator label="Befunde" />
<div className="space-y-2">
{insight.findings.map((finding, i) => (
<div key={i}>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<AssessmentIcon assessment={finding.assessment} />
<span className="text-sm">{finding.label}</span>
</div>
<span className="text-sm font-medium tabular-nums">
{finding.value}
</span>
</div>
{finding.guideline && (
<p className="ml-6 mt-0.5 text-xs text-muted-foreground">
{finding.guideline}
</p>
)}
</div>
))}
</div>
</div>
)}
{/* Correlations */}
{insight.correlations.length > 0 && (
<div className="space-y-3">
{/* TODO: i18n */}
<SectionSeparator label="Zusammenhänge" />
<div className="flex flex-wrap gap-2">
{insight.correlations.map((corr, i) => (
<div
key={i}
className="flex items-center gap-1.5 rounded-md bg-muted/50 px-2.5 py-1.5 text-xs"
>
<span>{corr.factor}</span>
<span className="text-muted-foreground">↔</span>
<span>{corr.effect}</span>
<span
className={`ml-1 font-medium ${CONFIDENCE_STYLES[corr.confidence] ?? ""}`}
>
{corr.confidence}
</span>
</div>
))}
</div>
</div>
)}
{/* Recommendations */}
{insight.recommendations.length > 0 && (
<div className="space-y-3">
{/* TODO: i18n */}
<SectionSeparator label="Empfehlungen" />
<ol className="space-y-1.5 pl-0">
{insight.recommendations.map((rec, i) => (
<li key={i} className="flex gap-2 text-sm">
<span className="shrink-0 font-medium text-muted-foreground">
{i + 1}.
</span>
<span>{rec}</span>
</li>
))}
</ol>
</div>
)}
{/* Data Quality (collapsible) */}
{insight.dataQuality && (
<div>
<button
onClick={() => setDataQualityOpen((v) => !v)}
className="flex w-full items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{dataQualityOpen ? (
<ChevronUp className="h-3.5 w-3.5" />
) : (
<ChevronDown className="h-3.5 w-3.5" />
)}
{/* TODO: i18n */}
<span>Datengrundlage</span>
<span className="mx-1">·</span>
<span>
Konfidenz:{" "}
<span
className={`font-medium ${CONFIDENCE_STYLES[insight.dataQuality.confidence] ?? ""}`}
>
{insight.dataQuality.confidence === "hoch"
? "Hoch"
: insight.dataQuality.confidence === "mittel"
? "Mittel"
: "Gering"}
</span>
</span>
</button>
{dataQualityOpen && (
<div className="mt-2 space-y-2 rounded-md bg-muted/30 px-3 py-2.5 text-xs text-muted-foreground animate-insight-in">
<p>{insight.dataQuality.coverage}</p>
{insight.dataQuality.gaps.length > 0 && (
<div>
{/* TODO: i18n */}
<p className="font-medium">Datenlücken:</p>
<ul className="mt-1 list-inside list-disc space-y-0.5">
{insight.dataQuality.gaps.map((gap, i) => (
<li key={i}>{gap}</li>
))}
</ul>
</div>
)}
</div>
)}
</div>
)}
{/* Cached timestamp */}
{cachedAt && (
<p className="text-xs text-muted-foreground">
{/* TODO: i18n */}
Zuletzt aktualisiert:{" "}
{new Date(cachedAt).toLocaleString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</p>
)}
{/* Disclaimer */}
<div className="flex items-start gap-1.5 pt-1">
<Info className="mt-0.5 h-3 w-3 shrink-0 text-muted-foreground" />
<p className="text-xs text-muted-foreground">
{insight.disclaimer}
</p>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,119 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Loader2 } from "lucide-react";
// ─── Types ────────────────────────────────────────────────
interface InsightStatusCardProps {
title: string;
icon: React.ReactNode;
text: string | null;
hasProvider: boolean;
cached: boolean;
updatedAt: string | null;
loading?: boolean;
}
// ─── Main Component ───────────────────────────────────────
export function InsightStatusCard({
title,
icon,
text,
hasProvider,
cached,
updatedAt,
loading = false,
}: InsightStatusCardProps) {
// ── Loading State ─────────────────────────────────────
if (loading) {
return (
<Card>
<CardContent className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-[#bd93f9]" />
<span className="ml-2 text-sm text-muted-foreground">
{/* TODO: i18n */}
Wird geladen...
</span>
</CardContent>
</Card>
);
}
// ── No Provider State ─────────────────────────────────
if (!hasProvider) {
return (
<Card className="opacity-60">
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
{icon}
<CardTitle className="text-base">{title}</CardTitle>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
{/* TODO: i18n */}
KI-Provider nicht konfiguriert.
</p>
</CardContent>
</Card>
);
}
// ── No Data State ─────────────────────────────────────
if (!text) {
return (
<Card>
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
{icon}
<CardTitle className="text-base">{title}</CardTitle>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
{/* TODO: i18n */}
Noch keine Analyse vorhanden.
</p>
</CardContent>
</Card>
);
}
// ── Populated Card ────────────────────────────────────
return (
<Card className="animate-insight-in border-l-2 border-l-[#bd93f9]">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{icon}
<CardTitle className="text-base">{title}</CardTitle>
</div>
{cached && (
<span className="text-xs text-muted-foreground">
{/* TODO: i18n */}
Zwischengespeichert
</span>
)}
</div>
</CardHeader>
<CardContent className="space-y-2">
<p className="text-sm leading-relaxed text-muted-foreground">{text}</p>
{updatedAt && (
<p className="text-xs text-muted-foreground">
{/* TODO: i18n */}
Zuletzt aktualisiert:{" "}
{new Date(updatedAt).toLocaleString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</p>
)}
</CardContent>
</Card>
);
}