2026-06-01 16:42:36 +07:00
|
|
|
import type { TrendBucket } from "../../../shared/api/client";
|
2026-06-01 21:44:29 +07:00
|
|
|
import {
|
|
|
|
|
Card,
|
|
|
|
|
CardContent,
|
|
|
|
|
CardDescription,
|
|
|
|
|
CardHeader,
|
|
|
|
|
CardTitle,
|
|
|
|
|
} from "../../../shared/ui";
|
2026-05-31 00:41:34 +07:00
|
|
|
|
|
|
|
|
interface TrendChartProps {
|
|
|
|
|
trend: TrendBucket[];
|
|
|
|
|
loading: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function TrendChart({ trend, loading }: TrendChartProps) {
|
|
|
|
|
if (loading && !trend?.length) {
|
|
|
|
|
return <LoadingBox />;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!trend?.length) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 01:04:26 +07:00
|
|
|
const data = trend.map((bucket) => ({
|
|
|
|
|
date: bucket.date,
|
|
|
|
|
clean: bucket.clean,
|
|
|
|
|
warned: bucket.warned,
|
|
|
|
|
flagged: bucket.flagged,
|
|
|
|
|
error: bucket.error,
|
|
|
|
|
total: bucket.count,
|
2026-05-31 00:41:34 +07:00
|
|
|
}));
|
|
|
|
|
|
2026-05-31 01:04:26 +07:00
|
|
|
const totalMessages = data.reduce((sum, item) => sum + item.total, 0);
|
|
|
|
|
|
2026-05-31 00:41:34 +07:00
|
|
|
return (
|
|
|
|
|
<Card className="col-span-3">
|
|
|
|
|
<CardHeader className="pb-2">
|
|
|
|
|
<CardTitle className="text-sm font-semibold">Tren Harian</CardTitle>
|
2026-06-01 21:44:29 +07:00
|
|
|
<CardDescription className="text-xs">
|
|
|
|
|
Volume pesan per hari dengan status moderasi.
|
|
|
|
|
</CardDescription>
|
2026-05-31 00:41:34 +07:00
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent>
|
2026-05-31 01:00:08 +07:00
|
|
|
<div className="space-y-4">
|
|
|
|
|
<div className="flex flex-wrap gap-3 text-[10px] uppercase tracking-wider text-muted-foreground">
|
2026-05-31 01:04:26 +07:00
|
|
|
<LegendDot color="bg-blue-500" label="Total" />
|
|
|
|
|
<LegendDot color="bg-emerald-500" label="Clean" />
|
|
|
|
|
<LegendDot color="bg-amber-500" label="Warned" />
|
|
|
|
|
<LegendDot color="bg-red-500" label="Flagged" />
|
2026-05-31 01:00:08 +07:00
|
|
|
</div>
|
2026-05-31 01:04:26 +07:00
|
|
|
|
|
|
|
|
<div className="overflow-hidden rounded-2xl border border-border bg-background/50 p-4">
|
|
|
|
|
<div className="mb-3 flex items-center justify-between text-[11px] text-muted-foreground">
|
|
|
|
|
<span>Rangkuman 7 hari terakhir</span>
|
|
|
|
|
<span>{totalMessages} total pesan</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="overflow-x-auto">
|
2026-06-01 21:44:29 +07:00
|
|
|
<svg
|
|
|
|
|
viewBox={`0 0 ${Math.max((data.length - 1) * 56, 56)} 220`}
|
|
|
|
|
className="h-55 min-w-130 w-full overflow-visible"
|
|
|
|
|
>
|
2026-05-31 01:04:26 +07:00
|
|
|
<defs>
|
|
|
|
|
<linearGradient id="trendFill" x1="0" x2="0" y1="0" y2="1">
|
|
|
|
|
<stop offset="0%" stopColor="#3b82f6" stopOpacity="0.35" />
|
2026-06-01 21:44:29 +07:00
|
|
|
<stop
|
|
|
|
|
offset="100%"
|
|
|
|
|
stopColor="#3b82f6"
|
|
|
|
|
stopOpacity="0.02"
|
|
|
|
|
/>
|
2026-05-31 01:04:26 +07:00
|
|
|
</linearGradient>
|
|
|
|
|
</defs>
|
|
|
|
|
|
|
|
|
|
<g stroke="#334155" strokeWidth="1" opacity="0.35">
|
|
|
|
|
{Array.from({ length: 4 }, (_, index) => {
|
|
|
|
|
const y = 40 + index * 45;
|
2026-06-01 21:44:29 +07:00
|
|
|
return (
|
|
|
|
|
<line
|
|
|
|
|
key={index}
|
|
|
|
|
x1="0"
|
|
|
|
|
x2={Math.max((data.length - 1) * 56, 56)}
|
|
|
|
|
y1={y}
|
|
|
|
|
y2={y}
|
|
|
|
|
/>
|
|
|
|
|
);
|
2026-05-31 01:04:26 +07:00
|
|
|
})}
|
|
|
|
|
</g>
|
|
|
|
|
|
2026-06-01 21:44:29 +07:00
|
|
|
<TrendArea
|
|
|
|
|
data={data}
|
|
|
|
|
keyName="total"
|
|
|
|
|
fill="url(#trendFill)"
|
|
|
|
|
stroke="#3b82f6"
|
|
|
|
|
/>
|
|
|
|
|
<TrendLine
|
|
|
|
|
data={data}
|
|
|
|
|
keyName="total"
|
|
|
|
|
color="#3b82f6"
|
|
|
|
|
strokeWidth={2.5}
|
|
|
|
|
/>
|
|
|
|
|
<TrendLine
|
|
|
|
|
data={data}
|
|
|
|
|
keyName="clean"
|
|
|
|
|
color="#10b981"
|
|
|
|
|
strokeWidth={1.8}
|
|
|
|
|
/>
|
|
|
|
|
<TrendLine
|
|
|
|
|
data={data}
|
|
|
|
|
keyName="warned"
|
|
|
|
|
color="#f59e0b"
|
|
|
|
|
strokeWidth={1.8}
|
|
|
|
|
/>
|
|
|
|
|
<TrendLine
|
|
|
|
|
data={data}
|
|
|
|
|
keyName="flagged"
|
|
|
|
|
color="#ef4444"
|
|
|
|
|
strokeWidth={1.8}
|
|
|
|
|
/>
|
2026-05-31 01:04:26 +07:00
|
|
|
|
|
|
|
|
{data.map((item, index) => {
|
2026-06-01 21:44:29 +07:00
|
|
|
const x =
|
|
|
|
|
data.length <= 1
|
|
|
|
|
? 0
|
|
|
|
|
: (index / (data.length - 1)) *
|
|
|
|
|
Math.max((data.length - 1) * 56, 56);
|
2026-05-31 01:04:26 +07:00
|
|
|
return (
|
|
|
|
|
<g key={item.date} transform={`translate(${x}, 188)`}>
|
|
|
|
|
<circle cx="0" cy="0" r="2.5" fill="#e2e8f0" />
|
2026-06-01 21:44:29 +07:00
|
|
|
<text
|
|
|
|
|
x="0"
|
|
|
|
|
y="18"
|
|
|
|
|
textAnchor="middle"
|
|
|
|
|
className="fill-muted-foreground text-[10px]"
|
|
|
|
|
>
|
2026-05-31 01:04:26 +07:00
|
|
|
{item.date.slice(5)}
|
|
|
|
|
</text>
|
|
|
|
|
</g>
|
|
|
|
|
);
|
2026-05-31 01:00:08 +07:00
|
|
|
})}
|
2026-05-31 01:04:26 +07:00
|
|
|
</svg>
|
2026-05-31 01:00:08 +07:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-05-31 00:41:34 +07:00
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 01:04:26 +07:00
|
|
|
function LegendDot({ color, label }: { color: string; label: string }) {
|
|
|
|
|
return (
|
|
|
|
|
<span className="flex items-center gap-1">
|
|
|
|
|
<span className={`h-2 w-2 rounded-full ${color}`} /> {label}
|
|
|
|
|
</span>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function TrendLine({
|
2026-05-31 01:00:08 +07:00
|
|
|
data,
|
|
|
|
|
color,
|
|
|
|
|
strokeWidth,
|
|
|
|
|
keyName,
|
|
|
|
|
}: {
|
|
|
|
|
data: Array<Record<string, number | string>>;
|
|
|
|
|
color: string;
|
|
|
|
|
strokeWidth: number;
|
|
|
|
|
keyName: string;
|
|
|
|
|
}) {
|
2026-05-31 01:04:26 +07:00
|
|
|
const path = buildPath(data, keyName, 220, false);
|
|
|
|
|
|
2026-06-01 21:44:29 +07:00
|
|
|
return (
|
|
|
|
|
<path
|
|
|
|
|
d={path}
|
|
|
|
|
fill="none"
|
|
|
|
|
stroke={color}
|
|
|
|
|
strokeWidth={strokeWidth}
|
|
|
|
|
strokeLinejoin="round"
|
|
|
|
|
strokeLinecap="round"
|
|
|
|
|
/>
|
|
|
|
|
);
|
2026-05-31 01:04:26 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function TrendArea({
|
|
|
|
|
data,
|
|
|
|
|
keyName,
|
|
|
|
|
fill,
|
|
|
|
|
stroke,
|
|
|
|
|
}: {
|
|
|
|
|
data: Array<Record<string, number | string>>;
|
|
|
|
|
keyName: string;
|
|
|
|
|
fill: string;
|
|
|
|
|
stroke: string;
|
|
|
|
|
}) {
|
|
|
|
|
const path = buildPath(data, keyName, 220, true);
|
|
|
|
|
return <path d={path} fill={fill} stroke={stroke} strokeOpacity={0.2} />;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 21:44:29 +07:00
|
|
|
function buildPath(
|
|
|
|
|
data: Array<Record<string, number | string>>,
|
|
|
|
|
keyName: string,
|
|
|
|
|
height: number,
|
|
|
|
|
closePath: boolean,
|
|
|
|
|
): string {
|
2026-05-31 01:00:08 +07:00
|
|
|
const values = data.map((item) => Number(item[keyName] ?? 0));
|
|
|
|
|
const maxValue = Math.max(...values, 1);
|
2026-05-31 01:04:26 +07:00
|
|
|
const width = Math.max((data.length - 1) * 56, 56);
|
2026-05-31 01:00:08 +07:00
|
|
|
const points = values.map((value, index) => {
|
2026-05-31 01:04:26 +07:00
|
|
|
const x = data.length <= 1 ? 0 : (index / (data.length - 1)) * width;
|
|
|
|
|
const y = height - 35 - (value / maxValue) * 130;
|
|
|
|
|
return { x, y };
|
2026-05-31 01:00:08 +07:00
|
|
|
});
|
|
|
|
|
|
2026-05-31 01:04:26 +07:00
|
|
|
if (points.length === 0) {
|
|
|
|
|
return "";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const segments: string[] = [`M ${points[0].x} ${points[0].y}`];
|
|
|
|
|
for (let index = 1; index < points.length; index++) {
|
|
|
|
|
const previous = points[index - 1];
|
|
|
|
|
const current = points[index];
|
|
|
|
|
const controlX = (previous.x + current.x) / 2;
|
|
|
|
|
segments.push(`Q ${controlX} ${previous.y} ${current.x} ${current.y}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (closePath) {
|
|
|
|
|
const lastPoint = points[points.length - 1];
|
|
|
|
|
const firstPoint = points[0];
|
|
|
|
|
segments.push(`L ${lastPoint.x} ${height - 24}`);
|
|
|
|
|
segments.push(`L ${firstPoint.x} ${height - 24}`);
|
|
|
|
|
segments.push("Z");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return segments.join(" ");
|
2026-05-31 00:41:34 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function LoadingBox() {
|
|
|
|
|
return (
|
|
|
|
|
<Card className="col-span-3">
|
2026-05-31 01:00:08 +07:00
|
|
|
<CardContent className="flex h-65 items-center justify-center text-sm text-muted-foreground">
|
2026-05-31 00:41:34 +07:00
|
|
|
<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>
|
|
|
|
|
);
|
|
|
|
|
}
|