feat(analytics): add warned status tracking to summary cards, trend chart, and violator table
This commit is contained in:
@@ -0,0 +1,147 @@
|
|||||||
|
import type { ModerationActionRecord } from "../../../shared/api/client";
|
||||||
|
import { cn } from "../../../shared/lib/utils";
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
ScrollArea,
|
||||||
|
} from "../../../shared/ui";
|
||||||
|
|
||||||
|
interface ModerationActionsPanelProps {
|
||||||
|
actions: ModerationActionRecord[];
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTION_LABELS: Record<string, { label: string; color: string }> = {
|
||||||
|
delete_message: { label: "Hapus Pesan", color: "bg-red-100 text-red-700 border-red-200" },
|
||||||
|
warn_user: { label: "Peringatan", color: "bg-yellow-100 text-yellow-700 border-yellow-200" },
|
||||||
|
mute_user: { label: "Mute", color: "bg-orange-100 text-orange-700 border-orange-200" },
|
||||||
|
kick_user: { label: "Kick", color: "bg-pink-100 text-pink-700 border-pink-200" },
|
||||||
|
ban_user: { label: "Ban", color: "bg-accent/20 text-accent border-accent/30" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
|
||||||
|
pending: { label: "Pending", color: "bg-gray-100 text-gray-600" },
|
||||||
|
completed: { label: "Selesai", color: "bg-green-100 text-green-700" },
|
||||||
|
executed: { label: "Tereksekusi", color: "bg-green-100 text-green-700" },
|
||||||
|
failed: { label: "Gagal", color: "bg-red-100 text-red-700" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ModerationActionsPanel({
|
||||||
|
actions,
|
||||||
|
loading,
|
||||||
|
}: ModerationActionsPanelProps) {
|
||||||
|
if (loading && !actions?.length) {
|
||||||
|
return <LoadingBox />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!actions?.length) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
|
||||||
|
Belum ada aksi moderasi.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
|
||||||
|
<span className="text-lg">🛡️</span>
|
||||||
|
Aksi Moderasi
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-xs">
|
||||||
|
Riwayat tindakan moderasi yang telah diambil.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Badge variant="secondary">{actions.length} aksi</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<ScrollArea className="max-h-[320px]">
|
||||||
|
<div className="divide-y divide-sky-50">
|
||||||
|
{actions.map((action) => {
|
||||||
|
const actionStyle = ACTION_LABELS[action.action_type] ?? {
|
||||||
|
label: action.action_type,
|
||||||
|
color: "bg-gray-100 text-gray-600",
|
||||||
|
};
|
||||||
|
const statusStyle = STATUS_LABELS[action.status] ?? {
|
||||||
|
label: action.status,
|
||||||
|
color: "bg-gray-100 text-gray-600",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={action.id}
|
||||||
|
className="px-5 py-3 text-sm hover:bg-sky-50/30 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"text-[10px] px-1.5 py-0 font-semibold whitespace-nowrap border",
|
||||||
|
actionStyle.color,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{actionStyle.label}
|
||||||
|
</Badge>
|
||||||
|
<span className="truncate text-xs font-medium">
|
||||||
|
{action.username}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"text-[9px] px-1.5 py-0 shrink-0",
|
||||||
|
statusStyle.color,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{statusStyle.label}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{action.reason && (
|
||||||
|
<p className="mt-1 text-[11px] text-muted-foreground line-clamp-1 pl-1">
|
||||||
|
{action.reason}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{action.error && (
|
||||||
|
<p className="mt-0.5 text-[10px] text-red-400 pl-1">
|
||||||
|
Error: {action.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-1 text-[10px] text-muted-foreground pl-1">
|
||||||
|
{new Date(action.created_at).toLocaleString("id-ID", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -31,12 +31,18 @@ export function SummaryCards({
|
|||||||
"Total Pesan": "💬",
|
"Total Pesan": "💬",
|
||||||
"Rata-rata/jam": "📊",
|
"Rata-rata/jam": "📊",
|
||||||
Clean: "✅",
|
Clean: "✅",
|
||||||
|
Warned: "⚠️",
|
||||||
Flagged: "🚩",
|
Flagged: "🚩",
|
||||||
Pending: "⏳",
|
Pending: "⏳",
|
||||||
"User Aktif": "👤",
|
"User Aktif": "👤",
|
||||||
Channel: "📡",
|
Channel: "📡",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const warnedPct =
|
||||||
|
messages && messages.total > 0
|
||||||
|
? Math.round((messages.warned / messages.total) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
const cards = [
|
const cards = [
|
||||||
{
|
{
|
||||||
label: "Total Pesan",
|
label: "Total Pesan",
|
||||||
@@ -53,6 +59,11 @@ export function SummaryCards({
|
|||||||
value: cleanPct > 0 ? `${cleanPct}%` : "—",
|
value: cleanPct > 0 ? `${cleanPct}%` : "—",
|
||||||
accent: "text-primary",
|
accent: "text-primary",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Warned",
|
||||||
|
value: warnedPct > 0 ? `${warnedPct}%` : "—",
|
||||||
|
accent: "text-yellow-600",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Flagged",
|
label: "Flagged",
|
||||||
value: flaggedPct > 0 ? `${flaggedPct}%` : "—",
|
value: flaggedPct > 0 ? `${flaggedPct}%` : "—",
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export function TrendChart({ trend, loading }: TrendChartProps) {
|
|||||||
const data = trend.map((bucket) => ({
|
const data = trend.map((bucket) => ({
|
||||||
date: bucket.date,
|
date: bucket.date,
|
||||||
clean: bucket.clean,
|
clean: bucket.clean,
|
||||||
warned: 0,
|
warned: bucket.warned,
|
||||||
flagged: bucket.flagged,
|
flagged: bucket.flagged,
|
||||||
error: bucket.error,
|
error: bucket.error,
|
||||||
total: bucket.count,
|
total: bucket.count,
|
||||||
@@ -37,7 +37,7 @@ export function TrendChart({ trend, loading }: TrendChartProps) {
|
|||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm font-semibold">Tren Harian</CardTitle>
|
<CardTitle className="text-sm font-semibold">Tren Harian</CardTitle>
|
||||||
<CardDescription className="text-xs">
|
<CardDescription className="text-xs">
|
||||||
Volume pesan per hari dengan status moderasi — area pink = flagged.
|
Volume pesan per hari dengan status moderasi.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -45,7 +45,9 @@ export function TrendChart({ trend, loading }: TrendChartProps) {
|
|||||||
<div className="flex flex-wrap gap-3 text-[10px] uppercase tracking-wider text-muted-foreground">
|
<div className="flex flex-wrap gap-3 text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||||
<LegendDot color="bg-primary" label="Total" />
|
<LegendDot color="bg-primary" label="Total" />
|
||||||
<LegendDot color="bg-emerald-400" label="Clean" />
|
<LegendDot color="bg-emerald-400" label="Clean" />
|
||||||
|
<LegendDot color="bg-yellow-400" label="Warned" />
|
||||||
<LegendDot color="bg-accent" label="Flagged" />
|
<LegendDot color="bg-accent" label="Flagged" />
|
||||||
|
<LegendDot color="bg-orange-300" label="Error" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-hidden rounded-2xl border border-sky-100 bg-white/60 p-4">
|
<div className="overflow-hidden rounded-2xl border border-sky-100 bg-white/60 p-4">
|
||||||
@@ -129,6 +131,18 @@ export function TrendChart({ trend, loading }: TrendChartProps) {
|
|||||||
fill="url(#trendFillPink)"
|
fill="url(#trendFillPink)"
|
||||||
stroke="#FF9EBB"
|
stroke="#FF9EBB"
|
||||||
/>
|
/>
|
||||||
|
<TrendLine
|
||||||
|
data={data}
|
||||||
|
keyName="warned"
|
||||||
|
color="#facc15"
|
||||||
|
strokeWidth={1.4}
|
||||||
|
/>
|
||||||
|
<TrendLine
|
||||||
|
data={data}
|
||||||
|
keyName="error"
|
||||||
|
color="#fdba74"
|
||||||
|
strokeWidth={1.2}
|
||||||
|
/>
|
||||||
|
|
||||||
{data.map((item, index) => {
|
{data.map((item, index) => {
|
||||||
const x =
|
const x =
|
||||||
|
|||||||
@@ -49,21 +49,23 @@ export function ViolatorTable({ users, loading }: ViolatorTableProps) {
|
|||||||
Pelanggar Terbanyak
|
Pelanggar Terbanyak
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-xs">
|
<CardDescription className="text-xs">
|
||||||
Skor: flagged × 3.
|
Skor: flagged × 3 + warned. Flag terbanyak terakhir ditampilkan.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="destructive">{users.length} pelanggar</Badge>
|
<Badge variant="destructive">{users.length} pelanggar</Badge>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<ScrollArea className="max-h-[260px]">
|
<ScrollArea className="max-h-[320px]">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="sticky top-0 z-10 bg-white border-b border-sky-100 text-left text-[10px] uppercase tracking-wider text-muted-foreground">
|
<tr className="sticky top-0 z-10 bg-white border-b border-sky-100 text-left text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||||
<th className="py-2 pl-4 pr-2 font-semibold">#</th>
|
<th className="py-2 pl-4 pr-2 font-semibold">#</th>
|
||||||
<th className="py-2 pr-2 font-semibold">User</th>
|
<th className="py-2 pr-2 font-semibold">User</th>
|
||||||
<th className="py-2 pr-2 font-semibold text-right">Flagged</th>
|
<th className="py-2 pr-2 font-semibold text-right">Flagged</th>
|
||||||
<th className="py-2 pr-4 font-semibold text-right">Skor</th>
|
<th className="py-2 pr-2 font-semibold text-right">Warned</th>
|
||||||
|
<th className="py-2 pr-2 font-semibold text-right">Skor</th>
|
||||||
|
<th className="py-2 pr-4 font-semibold">Flag</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-sky-50">
|
<tbody className="divide-y divide-sky-50">
|
||||||
@@ -113,9 +115,12 @@ export function ViolatorTable({ users, loading }: ViolatorTableProps) {
|
|||||||
<td className="py-1.5 pr-2 text-right font-mono text-xs text-accent tabular-nums">
|
<td className="py-1.5 pr-2 text-right font-mono text-xs text-accent tabular-nums">
|
||||||
{user.flagged_count}
|
{user.flagged_count}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-1.5 pr-4 text-right">
|
<td className="py-1.5 pr-2 text-right font-mono text-xs text-yellow-600 tabular-nums">
|
||||||
|
{user.warned_count > 0 ? user.warned_count : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 pr-2 text-right">
|
||||||
<div className="flex items-center justify-end gap-1.5">
|
<div className="flex items-center justify-end gap-1.5">
|
||||||
<div className="h-1.5 w-14 overflow-hidden rounded-full bg-pink-50">
|
<div className="h-1.5 w-12 overflow-hidden rounded-full bg-pink-50">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-full rounded-full",
|
"h-full rounded-full",
|
||||||
@@ -135,6 +140,21 @@ export function ViolatorTable({ users, loading }: ViolatorTableProps) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="py-1.5 pr-4">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{user.worst_flags?.length > 0
|
||||||
|
? user.worst_flags.slice(0, 3).map((flag) => (
|
||||||
|
<Badge
|
||||||
|
key={flag}
|
||||||
|
variant="outline"
|
||||||
|
className="text-[8px] px-1 py-0 border-accent/30 text-accent"
|
||||||
|
>
|
||||||
|
{flag}
|
||||||
|
</Badge>
|
||||||
|
))
|
||||||
|
: <span className="text-[10px] text-muted-foreground">—</span>}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
Reference in New Issue
Block a user