diff --git a/services/frontend/src/features/analytics/components/AIDistributionPanel.tsx b/services/frontend/src/features/analytics/components/AIDistributionPanel.tsx index c46d55b..80493ad 100644 --- a/services/frontend/src/features/analytics/components/AIDistributionPanel.tsx +++ b/services/frontend/src/features/analytics/components/AIDistributionPanel.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import type { AIStats } from "../../../shared/api/client"; import { cn } from "../../../shared/lib/utils"; import { @@ -13,80 +14,203 @@ interface AIDistributionPanelProps { loading: boolean; } -const SEVERITY_LABELS: Record< +const SEVERITY_META: Record< string, - { label: string; color: string; bar: string } + { label: string; color: string; darkColor: string } > = { critical: { label: "Critical", - color: "text-accent", - bar: "bg-gradient-to-r from-accent to-red-400", + color: "#e11d48", + darkColor: "#be123c", }, high: { label: "High", - color: "text-red-500", - bar: "bg-gradient-to-r from-red-400 to-red-300", + color: "#f43f5e", + darkColor: "#e11d48", }, medium: { label: "Medium", - color: "text-orange-500", - bar: "bg-gradient-to-r from-orange-400 to-yellow-300", + color: "#fb923c", + darkColor: "#f97316", }, low: { label: "Low", - color: "text-yellow-600", - bar: "bg-gradient-to-r from-yellow-300 to-primary/60", + color: "#facc15", + darkColor: "#eab308", }, none: { label: "None", - color: "text-muted-foreground", - bar: "bg-sky-200/50", + color: "#94a3b8", + darkColor: "#64748b", }, }; -const ACTION_LABELS: Record< +const ACTION_META: Record< string, - { label: string; color: string; bar: string } + { label: string; color: string } > = { - escalate: { - label: "Escalate", - color: "text-accent", - bar: "bg-gradient-to-r from-accent to-red-400", - }, - delete: { - 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", - }, + escalate: { label: "Escalate", color: "#e11d48" }, + delete: { label: "Delete", color: "#f43f5e" }, + review: { label: "Review", color: "#fb923c" }, + warn: { label: "Warn", color: "#facc15" }, + monitor: { label: "Monitor", color: "#38bdf8" }, + none: { label: "None", color: "#94a3b8" }, }; +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(null); + + if (total === 0) { + return ( +
+ No data +
+ ); + } + + 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 ( +
+ + {/* Background ring */} + + {/* Segments */} + {segments.map((seg) => { + const isHovered = hoveredKey === seg.key; + return ( + setHoveredKey(seg.key)} + onMouseLeave={() => setHoveredKey(null)} + style={{ + filter: isHovered ? `drop-shadow(0 0 4px ${seg.color}80)` : undefined, + }} + /> + ); + })} + + + {/* Center label */} +
+ + {total} + + Total +
+ + {/* Hover tooltip */} + {hoveredKey && (() => { + const entry = entries.find((e) => e.key === hoveredKey); + if (!entry) return null; + const pct = ((entry.value / total) * 100).toFixed(0); + return ( +
+ {entry.label}:{" "} + {entry.value} ({pct}%) +
+ ); + })()} +
+ ); +} + +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(null); + + return ( +
+ {entries.map((e) => { + const isHovered = hoveredKey === e.key; + const widthPct = (e.value / effectiveMax) * 100; + return ( +
setHoveredKey(e.key)} + onMouseLeave={() => setHoveredKey(null)} + > + + {e.label} + +
+
+
+ + {e.value} + +
+ ); + })} +
+ ); +} + export function AIDistributionPanel({ stats, loading, }: AIDistributionPanelProps) { - if (loading && !stats) { - return ; - } + if (loading && !stats) return ; if (!stats || stats.total_analyzed === 0) { return ( @@ -98,11 +222,31 @@ export function AIDistributionPanel({ ); } - const severityEntries = Object.entries(stats.severity); - const maxSeverity = Math.max(...severityEntries.map(([, v]) => v), 1); + const severityEntries = Object.entries(stats.severity) + .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 maxAction = Math.max(...actionEntries.map(([, v]) => v), 1); + const actionEntries = Object.entries(stats.recommended_actions) + .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 ( @@ -112,105 +256,72 @@ export function AIDistributionPanel({ Distribusi Analisis AI - Sebaran tingkat keparahan dan rekomendasi dari {stats.total_analyzed}{" "} - pesan yang dianalisis. + Sebaran tingkat keparahan dan rekomendasi dari{" "} + {stats.total_analyzed} pesan yang dianalisis. -
- {/* Severity */} -
-

+
+ {/* Severity Donut */} +
+

Severity

-
- {severityEntries.reverse().map(([key, value]) => { - const s = SEVERITY_LABELS[key] ?? { - label: key, - color: "text-muted-foreground", - bar: "bg-sky-200/50", - }; - return ( -
- - {s.label} - -
-
-
- - {value} - -
- ); - })} + + {/* Severity legend */} +
+ {severityEntries.map((e) => ( + + + {e.label}:{" "} + + {e.value} + + + ))}
- {/* Recommended Actions */} + {/* Recommended Actions Bar Chart */}

Rekomendasi Tindakan

-
- {actionEntries.map(([key, value]) => { - const a = ACTION_LABELS[key] ?? { - label: key, - color: "text-muted-foreground", - bar: "bg-sky-200/50", - }; - return ( -
- - {a.label} - -
-
-
- - {value} - -
- ); - })} -
+
+
- {/* Footer metrics */} -
- - Rerata confidence:{" "} - {(stats.avg_confidence * 100).toFixed(0)}% - - - Rerata score:{" "} - {(stats.avg_score * 100).toFixed(0)}% - - - Error:{" "} - {stats.analysis_errors} - - - Pending:{" "} - - {stats.analysis_pending} - - -
+ {/* Footer metrics */} +
+ + Rerata confidence:{" "} + {(stats.avg_confidence * 100).toFixed(0)}% + + + Rerata score:{" "} + {(stats.avg_score * 100).toFixed(0)}% + + + Error:{" "} + + {stats.analysis_errors} + + + + Pending:{" "} + + {stats.analysis_pending} + +
@@ -221,7 +332,7 @@ function LoadingBox() { return ( - + Memuat data...