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 { cardItem, cardStagger } from "../../shared/hooks/useFramerStagger";
|
||||||
import { EmptyStateMascot } from "../../shared/ui";
|
import { EmptyStateMascot } from "../../shared/ui";
|
||||||
import { ActivityChart } from "./components/ActivityChart";
|
import { ActivityChart } from "./components/ActivityChart";
|
||||||
|
import { AIDistributionPanel } from "./components/AIDistributionPanel";
|
||||||
|
import { AttachmentStatsPanel } from "./components/AttachmentStatsPanel";
|
||||||
import { ControlBar } from "./components/ControlBar";
|
import { ControlBar } from "./components/ControlBar";
|
||||||
import { Heatmap } from "./components/Heatmap";
|
import { Heatmap } from "./components/Heatmap";
|
||||||
|
import { ModerationActionsPanel } from "./components/ModerationActionsPanel";
|
||||||
import { SummaryCards } from "./components/SummaryCards";
|
import { SummaryCards } from "./components/SummaryCards";
|
||||||
import { TopicList } from "./components/TopicList";
|
import { TopicList } from "./components/TopicList";
|
||||||
import { TrendChart } from "./components/TrendChart";
|
import { TrendChart } from "./components/TrendChart";
|
||||||
@@ -35,6 +38,9 @@ export function AnalyticsPanel({ guildId, guildName }: AnalyticsPanelProps) {
|
|||||||
violators,
|
violators,
|
||||||
trend,
|
trend,
|
||||||
heatmap,
|
heatmap,
|
||||||
|
aiStats,
|
||||||
|
attachmentStats,
|
||||||
|
moderationActions,
|
||||||
isLoading,
|
isLoading,
|
||||||
isFetching,
|
isFetching,
|
||||||
error,
|
error,
|
||||||
@@ -105,7 +111,16 @@ export function AnalyticsPanel({ guildId, guildName }: AnalyticsPanelProps) {
|
|||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
<motion.div variants={cardItem}>
|
<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>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user