feat(analytics): add AI distribution and attachment stats panels
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user