refactor(analytics): replace gradient bars with interactive SVG donut and horizontal bar charts
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user