refactor(analytics): replace gradient bars with interactive SVG donut and horizontal bar charts

This commit is contained in:
MythEclipse
2026-06-04 14:15:27 +07:00
parent 00001973d8
commit ceb5fc95ba
@@ -1,3 +1,4 @@
import { useState } from "react";
import type { AIStats } from "../../../shared/api/client"; import type { AIStats } from "../../../shared/api/client";
import { cn } from "../../../shared/lib/utils"; import { cn } from "../../../shared/lib/utils";
import { import {
@@ -13,80 +14,203 @@ interface AIDistributionPanelProps {
loading: boolean; loading: boolean;
} }
const SEVERITY_LABELS: Record< const SEVERITY_META: Record<
string, string,
{ label: string; color: string; bar: string } { label: string; color: string; darkColor: string }
> = { > = {
critical: { critical: {
label: "Critical", label: "Critical",
color: "text-accent", color: "#e11d48",
bar: "bg-gradient-to-r from-accent to-red-400", darkColor: "#be123c",
}, },
high: { high: {
label: "High", label: "High",
color: "text-red-500", color: "#f43f5e",
bar: "bg-gradient-to-r from-red-400 to-red-300", darkColor: "#e11d48",
}, },
medium: { medium: {
label: "Medium", label: "Medium",
color: "text-orange-500", color: "#fb923c",
bar: "bg-gradient-to-r from-orange-400 to-yellow-300", darkColor: "#f97316",
}, },
low: { low: {
label: "Low", label: "Low",
color: "text-yellow-600", color: "#facc15",
bar: "bg-gradient-to-r from-yellow-300 to-primary/60", darkColor: "#eab308",
}, },
none: { none: {
label: "None", label: "None",
color: "text-muted-foreground", color: "#94a3b8",
bar: "bg-sky-200/50", darkColor: "#64748b",
}, },
}; };
const ACTION_LABELS: Record< const ACTION_META: Record<
string, string,
{ label: string; color: string; bar: string } { label: string; color: string }
> = { > = {
escalate: { escalate: { label: "Escalate", color: "#e11d48" },
label: "Escalate", delete: { label: "Delete", color: "#f43f5e" },
color: "text-accent", review: { label: "Review", color: "#fb923c" },
bar: "bg-gradient-to-r from-accent to-red-400", warn: { label: "Warn", color: "#facc15" },
}, monitor: { label: "Monitor", color: "#38bdf8" },
delete: { none: { label: "None", color: "#94a3b8" },
label: "Delete",
color: "text-red-500",
bar: "bg-gradient-to-r from-red-400 to-red-300",
},
review: {
label: "Review",
color: "text-orange-500",
bar: "bg-gradient-to-r from-orange-400 to-yellow-300",
},
warn: {
label: "Warn",
color: "text-yellow-600",
bar: "bg-gradient-to-r from-yellow-300 to-primary/60",
},
monitor: {
label: "Monitor",
color: "text-primary",
bar: "bg-gradient-to-r from-primary to-teal-300",
},
none: {
label: "None",
color: "text-muted-foreground",
bar: "bg-sky-200/50",
},
}; };
function DonutChart({
entries,
size = 140,
strokeWidth = 22,
}: {
entries: Array<{ key: string; value: number; color: string; label: string }>;
size?: number;
strokeWidth?: number;
}) {
const total = entries.reduce((sum, e) => sum + e.value, 0);
const [hoveredKey, setHoveredKey] = useState<string | null>(null);
if (total === 0) {
return (
<div className="flex items-center justify-center text-[11px] text-muted-foreground">
No data
</div>
);
}
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const center = size / 2;
let cumulative = 0;
const segments = entries
.filter((e) => e.value > 0)
.map((e) => {
const offset = cumulative;
const length = (e.value / total) * circumference;
cumulative += length;
return { ...e, length, offset };
});
return (
<div className="relative" style={{ width: size, height: size }}>
<svg width={size} height={size} className="-rotate-90">
{/* Background ring */}
<circle
cx={center}
cy={center}
r={radius}
fill="none"
stroke="hsl(var(--muted))"
strokeWidth={strokeWidth}
opacity={0.2}
/>
{/* Segments */}
{segments.map((seg) => {
const isHovered = hoveredKey === seg.key;
return (
<circle
key={seg.key}
cx={center}
cy={center}
r={radius}
fill="none"
stroke={seg.color}
strokeWidth={strokeWidth}
strokeDasharray={`${seg.length} ${circumference - seg.length}`}
strokeDashoffset={-seg.offset}
strokeLinecap="round"
className={cn(
"transition-all duration-200",
hoveredKey && !isHovered ? "opacity-30" : "opacity-100",
)}
onMouseEnter={() => setHoveredKey(seg.key)}
onMouseLeave={() => setHoveredKey(null)}
style={{
filter: isHovered ? `drop-shadow(0 0 4px ${seg.color}80)` : undefined,
}}
/>
);
})}
</svg>
{/* Center label */}
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
<span className="text-lg font-bold tabular-nums leading-none">
{total}
</span>
<span className="text-[9px] text-muted-foreground mt-0.5">Total</span>
</div>
{/* Hover tooltip */}
{hoveredKey && (() => {
const entry = entries.find((e) => e.key === hoveredKey);
if (!entry) return null;
const pct = ((entry.value / total) * 100).toFixed(0);
return (
<div
className="absolute -bottom-8 left-1/2 -translate-x-1/2 whitespace-nowrap rounded-md border border-muted bg-white px-2.5 py-1 text-xs shadow-lg z-10"
>
<span className="font-medium">{entry.label}</span>:{" "}
<span className="tabular-nums">{entry.value}</span> ({pct}%)
</div>
);
})()}
</div>
);
}
function HorizontalBarChart({
entries,
maxValue,
}: {
entries: Array<{ key: string; value: number; color: string; label: string }>;
maxValue: number;
}) {
const effectiveMax = Math.max(maxValue, 1);
const [hoveredKey, setHoveredKey] = useState<string | null>(null);
return (
<div className="space-y-1.5">
{entries.map((e) => {
const isHovered = hoveredKey === e.key;
const widthPct = (e.value / effectiveMax) * 100;
return (
<div
key={e.key}
className="flex items-center gap-2"
onMouseEnter={() => setHoveredKey(e.key)}
onMouseLeave={() => setHoveredKey(null)}
>
<span className="w-16 text-[10px] font-medium text-right truncate text-muted-foreground">
{e.label}
</span>
<div className="flex-1 h-3 overflow-hidden rounded-md bg-muted/30">
<div
className={cn(
"h-full rounded-md transition-all duration-300",
isHovered ? "opacity-100" : "opacity-80",
)}
style={{
width: `${widthPct}%`,
backgroundColor: e.color,
}}
/>
</div>
<span className="w-8 text-right font-mono text-[10px] tabular-nums text-muted-foreground">
{e.value}
</span>
</div>
);
})}
</div>
);
}
export function AIDistributionPanel({ export function AIDistributionPanel({
stats, stats,
loading, loading,
}: AIDistributionPanelProps) { }: AIDistributionPanelProps) {
if (loading && !stats) { if (loading && !stats) return <LoadingBox />;
return <LoadingBox />;
}
if (!stats || stats.total_analyzed === 0) { if (!stats || stats.total_analyzed === 0) {
return ( return (
@@ -98,11 +222,31 @@ export function AIDistributionPanel({
); );
} }
const severityEntries = Object.entries(stats.severity); const severityEntries = Object.entries(stats.severity)
const maxSeverity = Math.max(...severityEntries.map(([, v]) => v), 1); .map(([key, value]) => {
const m = SEVERITY_META[key] ?? {
label: key,
color: "#94a3b8",
darkColor: "#64748b",
};
return { key, value, color: m.color, label: m.label };
})
.filter((e) => e.value > 0);
const actionEntries = Object.entries(stats.recommended_actions); const actionEntries = Object.entries(stats.recommended_actions)
const maxAction = Math.max(...actionEntries.map(([, v]) => v), 1); .map(([key, value]) => {
const m = ACTION_META[key] ?? {
label: key,
color: "#94a3b8",
};
return { key, value, color: m.color, label: m.label };
})
.filter((e) => e.value > 0);
const maxAction = Math.max(
...actionEntries.map((e) => e.value),
1,
);
return ( return (
<Card> <Card>
@@ -112,105 +256,72 @@ export function AIDistributionPanel({
Distribusi Analisis AI Distribusi Analisis AI
</CardTitle> </CardTitle>
<CardDescription className="text-xs"> <CardDescription className="text-xs">
Sebaran tingkat keparahan dan rekomendasi dari {stats.total_analyzed}{" "} Sebaran tingkat keparahan dan rekomendasi dari{" "}
pesan yang dianalisis. {stats.total_analyzed} pesan yang dianalisis.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
{/* Severity */} {/* Severity Donut */}
<div> <div className="flex flex-col items-center gap-3">
<h4 className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"> <h4 className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground self-start">
Severity Severity
</h4> </h4>
<div className="space-y-1.5"> <DonutChart entries={severityEntries} />
{severityEntries.reverse().map(([key, value]) => { {/* Severity legend */}
const s = SEVERITY_LABELS[key] ?? { <div className="flex flex-wrap justify-center gap-x-3 gap-y-1">
label: key, {severityEntries.map((e) => (
color: "text-muted-foreground", <span
bar: "bg-sky-200/50", key={e.key}
}; className="flex items-center gap-1 text-[10px] text-muted-foreground"
return ( >
<div key={key} className="flex items-center gap-2"> <span
<span className="inline-block h-2 w-2 rounded-sm"
className={cn("w-16 text-[10px] font-medium", s.color)} style={{ backgroundColor: e.color }}
> />
{s.label} {e.label}:{" "}
</span> <span className="font-medium tabular-nums text-foreground">
<div className="flex-1 h-2 overflow-hidden rounded-full bg-sky-50"> {e.value}
<div </span>
className={cn("h-full rounded-full", s.bar)} </span>
style={{ ))}
width: `${(value / maxSeverity) * 100}%`,
}}
/>
</div>
<span className="w-8 text-right font-mono text-[10px] tabular-nums text-muted-foreground">
{value}
</span>
</div>
);
})}
</div> </div>
</div> </div>
{/* Recommended Actions */} {/* Recommended Actions Bar Chart */}
<div> <div>
<h4 className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"> <h4 className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
Rekomendasi Tindakan Rekomendasi Tindakan
</h4> </h4>
<div className="space-y-1.5"> <HorizontalBarChart
{actionEntries.map(([key, value]) => { entries={actionEntries}
const a = ACTION_LABELS[key] ?? { maxValue={maxAction}
label: key, />
color: "text-muted-foreground",
bar: "bg-sky-200/50",
};
return (
<div key={key} className="flex items-center gap-2">
<span
className={cn("w-16 text-[10px] font-medium", a.color)}
>
{a.label}
</span>
<div className="flex-1 h-2 overflow-hidden rounded-full bg-sky-50">
<div
className={cn("h-full rounded-full", a.bar)}
style={{
width: `${(value / maxAction) * 100}%`,
}}
/>
</div>
<span className="w-8 text-right font-mono text-[10px] tabular-nums text-muted-foreground">
{value}
</span>
</div>
);
})}
</div>
</div> </div>
</div>
{/* Footer metrics */} {/* Footer metrics */}
<div className="flex flex-wrap gap-3 border-t border-sky-100 pt-3 text-[10px] text-muted-foreground"> <div className="mt-4 flex flex-wrap gap-3 border-t border-muted pt-3 text-[10px] text-muted-foreground">
<span> <span>
Rerata confidence:{" "} Rerata confidence:{" "}
<strong>{(stats.avg_confidence * 100).toFixed(0)}%</strong> <strong>{(stats.avg_confidence * 100).toFixed(0)}%</strong>
</span> </span>
<span> <span>
Rerata score:{" "} Rerata score:{" "}
<strong>{(stats.avg_score * 100).toFixed(0)}%</strong> <strong>{(stats.avg_score * 100).toFixed(0)}%</strong>
</span> </span>
<span> <span>
Error:{" "} Error:{" "}
<strong className="text-red-500">{stats.analysis_errors}</strong> <strong className="text-destructive">
</span> {stats.analysis_errors}
<span> </strong>
Pending:{" "} </span>
<strong className="text-yellow-600"> <span>
{stats.analysis_pending} Pending:{" "}
</strong> <strong className="text-orange-500">
</span> {stats.analysis_pending}
</div> </strong>
</span>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -221,7 +332,7 @@ function LoadingBox() {
return ( return (
<Card> <Card>
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground"> <CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" /> <span className="h-4 w-4 animate-spin rounded-sm border-2 border-current border-t-transparent" />
<span className="ml-2">Memuat data...</span> <span className="ml-2">Memuat data...</span>
</CardContent> </CardContent>
</Card> </Card>