From b8979cbee1eeb6063df930369d2049817e1fec53 Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Wed, 3 Jun 2026 18:19:41 +0700 Subject: [PATCH] feat(analytics): add AI distribution and attachment stats panels --- .../components/AIDistributionPanel.tsx | 235 ++++++++++++++++++ .../components/AttachmentStatsPanel.tsx | 150 +++++++++++ .../frontend/src/features/analytics/index.tsx | 17 +- 3 files changed, 401 insertions(+), 1 deletion(-) create mode 100644 services/frontend/src/features/analytics/components/AIDistributionPanel.tsx create mode 100644 services/frontend/src/features/analytics/components/AttachmentStatsPanel.tsx diff --git a/services/frontend/src/features/analytics/components/AIDistributionPanel.tsx b/services/frontend/src/features/analytics/components/AIDistributionPanel.tsx new file mode 100644 index 0000000..df69bb2 --- /dev/null +++ b/services/frontend/src/features/analytics/components/AIDistributionPanel.tsx @@ -0,0 +1,235 @@ +import type { AIStats } from "../../../shared/api/client"; +import { cn } from "../../../shared/lib/utils"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "../../../shared/ui"; + +interface AIDistributionPanelProps { + stats: AIStats | null; + loading: boolean; +} + +const SEVERITY_LABELS: Record< + string, + { label: string; color: string; bar: string } +> = { + critical: { + label: "Critical", + color: "text-accent", + bar: "bg-gradient-to-r from-accent to-red-400", + }, + high: { + label: "High", + color: "text-red-500", + bar: "bg-gradient-to-r from-red-400 to-red-300", + }, + medium: { + label: "Medium", + color: "text-orange-500", + bar: "bg-gradient-to-r from-orange-400 to-yellow-300", + }, + low: { + label: "Low", + color: "text-yellow-600", + bar: "bg-gradient-to-r from-yellow-300 to-primary/60", + }, + none: { + label: "None", + color: "text-muted-foreground", + bar: "bg-sky-200/50", + }, +}; + +const ACTION_LABELS: Record< + string, + { label: string; color: string; bar: 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", + }, +}; + +export function AIDistributionPanel({ + stats, + loading, +}: AIDistributionPanelProps) { + if (loading && !stats) { + return ; + } + + if (!stats || stats.total_analyzed === 0) { + return ( + + + Belum ada data analisis AI. + + + ); + } + + const severityEntries = Object.entries(stats.severity); + const maxSeverity = Math.max(...severityEntries.map(([, v]) => v), 1); + + const actionEntries = Object.entries(stats.recommended_actions); + const maxAction = Math.max(...actionEntries.map(([, v]) => v), 1); + + return ( + + + + 🤖 + Distribusi Analisis AI + + + Sebaran tingkat keparahan dan rekomendasi dari {stats.total_analyzed} pesan + yang dianalisis. + + + +
+ {/* Severity */} +
+

+ 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} + +
+ ); + })} +
+
+ + {/* Recommended Actions */} +
+

+ 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} + + +
+
+ + + ); +} + +function LoadingBox() { + return ( + + + + Memuat data... + + + ); +} diff --git a/services/frontend/src/features/analytics/components/AttachmentStatsPanel.tsx b/services/frontend/src/features/analytics/components/AttachmentStatsPanel.tsx new file mode 100644 index 0000000..5d32f8a --- /dev/null +++ b/services/frontend/src/features/analytics/components/AttachmentStatsPanel.tsx @@ -0,0 +1,150 @@ +import type { AttachmentStats } from "../../../shared/api/client"; +import { cn } from "../../../shared/lib/utils"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "../../../shared/ui"; + +interface AttachmentStatsPanelProps { + stats: AttachmentStats | null; + loading: boolean; +} + +export function AttachmentStatsPanel({ + stats, + loading, +}: AttachmentStatsPanelProps) { + if (loading && !stats) { + return ; + } + + if (!stats || stats.total_attachments === 0) { + return ( + + + Belum ada lampiran/media. + + + ); + } + + const uploadPct = stats.total_attachments > 0 + ? Math.round((stats.uploaded / stats.total_attachments) * 100) + : 0; + + const failedPct = stats.total_attachments > 0 + ? Math.round((stats.failed / stats.total_attachments) * 100) + : 0; + + const totalSizeMB = stats.total_size_bytes / (1024 * 1024); + + const cards = [ + { label: "Total Media", value: formatNum(stats.total_attachments), accent: "text-foreground" }, + { label: "Upload Success", value: `${uploadPct}%`, accent: "text-primary" }, + { label: "Gagal Upload", value: `${failedPct}%`, accent: "text-accent" }, + { label: "Total Ukuran", value: `${totalSizeMB.toFixed(1)} MB`, accent: "text-muted-foreground" }, + { label: "Pengupload", value: formatNum(stats.unique_uploaders), accent: "text-primary" }, + ]; + + const statusCards = [ + { + label: "Uploaded", + value: formatNum(stats.uploaded), + total: stats.total_attachments, + barClass: "bg-gradient-to-r from-primary to-teal-300", + }, + { + label: "Pending", + value: formatNum(stats.pending), + total: stats.total_attachments, + barClass: "bg-yellow-400/60", + }, + { + label: "Failed", + value: formatNum(stats.failed), + total: stats.total_attachments, + barClass: "bg-accent/70", + }, + ]; + + return ( + + + + 🖼️ + Statistik Media + + + {stats.top_mime_type ? ( + <> + Upload status media — tipe dominan:{" "} + {stats.top_mime_type} + + ) : ( + "Upload status media di semua channel." + )} + + + +
+ {cards.map((c) => ( +
+
+ {c.label} +
+
+ {c.value} +
+
+ ))} +
+ +
+

+ Status Upload +

+ {statusCards.map((s) => ( +
+ + {s.label} + +
+
+
+ + {s.value} + +
+ ))} +
+ + + ); +} + +function formatNum(v: number | undefined | null): string { + if (v == null || v === 0) return "0"; + return v.toLocaleString("id-ID"); +} + +function LoadingBox() { + return ( + + + + Memuat data... + + + ); +} diff --git a/services/frontend/src/features/analytics/index.tsx b/services/frontend/src/features/analytics/index.tsx index 6338edd..2d3c980 100644 --- a/services/frontend/src/features/analytics/index.tsx +++ b/services/frontend/src/features/analytics/index.tsx @@ -3,8 +3,11 @@ import { useState } from "react"; import { cardItem, cardStagger } from "../../shared/hooks/useFramerStagger"; import { EmptyStateMascot } from "../../shared/ui"; import { ActivityChart } from "./components/ActivityChart"; +import { AIDistributionPanel } from "./components/AIDistributionPanel"; +import { AttachmentStatsPanel } from "./components/AttachmentStatsPanel"; import { ControlBar } from "./components/ControlBar"; import { Heatmap } from "./components/Heatmap"; +import { ModerationActionsPanel } from "./components/ModerationActionsPanel"; import { SummaryCards } from "./components/SummaryCards"; import { TopicList } from "./components/TopicList"; import { TrendChart } from "./components/TrendChart"; @@ -35,6 +38,9 @@ export function AnalyticsPanel({ guildId, guildName }: AnalyticsPanelProps) { violators, trend, heatmap, + aiStats, + attachmentStats, + moderationActions, isLoading, isFetching, error, @@ -105,7 +111,16 @@ export function AnalyticsPanel({ guildId, guildName }: AnalyticsPanelProps) {
- +
+ + +
+
+ +
+ + +
);