feat(analytics): add AI distribution and attachment stats panels

This commit is contained in:
MythEclipse
2026-06-03 18:19:41 +07:00
parent 1669614735
commit b8979cbee1
3 changed files with 401 additions and 1 deletions
@@ -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 <LoadingBox />;
}
if (!stats || stats.total_analyzed === 0) {
return (
<Card>
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
Belum ada data analisis AI.
</CardContent>
</Card>
);
}
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 (
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<span className="text-lg">🤖</span>
Distribusi Analisis AI
</CardTitle>
<CardDescription className="text-xs">
Sebaran tingkat keparahan dan rekomendasi dari {stats.total_analyzed} pesan
yang dianalisis.
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Severity */}
<div>
<h4 className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
Severity
</h4>
<div className="space-y-1.5">
{severityEntries.reverse().map(([key, value]) => {
const s = SEVERITY_LABELS[key] ?? {
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",
s.color,
)}
>
{s.label}
</span>
<div className="flex-1 h-2 overflow-hidden rounded-full bg-sky-50">
<div
className={cn("h-full rounded-full", s.bar)}
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>
{/* Recommended Actions */}
<div>
<h4 className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
Rekomendasi Tindakan
</h4>
<div className="space-y-1.5">
{actionEntries.map(([key, value]) => {
const a = ACTION_LABELS[key] ?? {
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>
{/* Footer metrics */}
<div className="flex flex-wrap gap-3 border-t border-sky-100 pt-3 text-[10px] text-muted-foreground">
<span>
Rerata confidence:{" "}
<strong>{(stats.avg_confidence * 100).toFixed(0)}%</strong>
</span>
<span>
Rerata score:{" "}
<strong>{(stats.avg_score * 100).toFixed(0)}%</strong>
</span>
<span>
Error:{" "}
<strong className="text-red-500">{stats.analysis_errors}</strong>
</span>
<span>
Pending:{" "}
<strong className="text-yellow-600">
{stats.analysis_pending}
</strong>
</span>
</div>
</div>
</CardContent>
</Card>
);
}
function LoadingBox() {
return (
<Card>
<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="ml-2">Memuat data...</span>
</CardContent>
</Card>
);
}
@@ -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 <LoadingBox />;
}
if (!stats || stats.total_attachments === 0) {
return (
<Card>
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
Belum ada lampiran/media.
</CardContent>
</Card>
);
}
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 (
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<span className="text-lg">🖼</span>
Statistik Media
</CardTitle>
<CardDescription className="text-xs">
{stats.top_mime_type ? (
<>
Upload status media tipe dominan:{" "}
<span className="font-medium text-primary">{stats.top_mime_type}</span>
</>
) : (
"Upload status media di semua channel."
)}
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2 mb-4">
{cards.map((c) => (
<div
key={c.label}
className="rounded-xl border border-sky-100 bg-white p-3"
>
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
{c.label}
</div>
<div className={cn("mt-1 font-mono text-lg font-bold tabular-nums", c.accent)}>
{c.value}
</div>
</div>
))}
</div>
<div className="space-y-2">
<h4 className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
Status Upload
</h4>
{statusCards.map((s) => (
<div key={s.label} className="flex items-center gap-2">
<span className="w-20 text-[10px] font-medium text-muted-foreground">
{s.label}
</span>
<div className="flex-1 h-2 overflow-hidden rounded-full bg-sky-50">
<div
className={cn("h-full rounded-full", s.barClass)}
style={{
width: `${(Number(s.value.replace(/\./g, "")) / Math.max(s.total, 1)) * 100}%`,
}}
/>
</div>
<span className="w-10 text-right font-mono text-[10px] tabular-nums text-muted-foreground">
{s.value}
</span>
</div>
))}
</div>
</CardContent>
</Card>
);
}
function formatNum(v: number | undefined | null): string {
if (v == null || v === 0) return "0";
return v.toLocaleString("id-ID");
}
function LoadingBox() {
return (
<Card>
<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="ml-2">Memuat data...</span>
</CardContent>
</Card>
);
}
@@ -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) {
</div>
</motion.div>
<motion.div variants={cardItem}>
<ViolatorTable users={violators} loading={loading} />
<div className="grid grid-cols-2 gap-4">
<AIDistributionPanel stats={aiStats} loading={loading} />
<AttachmentStatsPanel stats={attachmentStats} loading={loading} />
</div>
</motion.div>
<motion.div variants={cardItem}>
<div className="grid grid-cols-2 gap-4">
<ViolatorTable users={violators} loading={loading} />
<ModerationActionsPanel actions={moderationActions} loading={loading} />
</div>
</motion.div>
</motion.div>
);