feat(dashboard, i18n): 增强仪表盘视觉效果与多语言支持
在英文、尼泊尔语和中文语言包中新增 “Other statuses” 翻译,提升仪表盘指标展示的清晰度。 在仪表盘中集成新的 StatCard 组件,用于更直观地展示关键指标数据。 更新仪表盘趋势图表,采用 recharts 实现更丰富的数据可视化效果。 重构现有组件与布局,优化整体交互与用户体验,使界面更加直观易用。
This commit is contained in:
@@ -1,10 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
Cell,
|
||||
Label,
|
||||
Pie,
|
||||
PieChart,
|
||||
PolarAngleAxis,
|
||||
RadialBar,
|
||||
RadialBarChart,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from "@/components/ui/chart";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
buildBatchProgressConfig,
|
||||
buildFinanceStructureConfig,
|
||||
buildPayoutPieConfig,
|
||||
buildSoldOutPieConfig,
|
||||
buildSettlementBarConfig,
|
||||
buildUsageBarConfig,
|
||||
DASHBOARD_CHART_COLORS,
|
||||
} from "@/modules/dashboard/dashboard-chart-config";
|
||||
import { DashboardChartEmpty } from "@/modules/dashboard/dashboard-chart-empty";
|
||||
import type { AdminDrawFinanceSummaryData } from "@/types/api/admin-draw-finance";
|
||||
import type { AdminRiskPoolRow } from "@/types/api/admin-risk";
|
||||
import type {
|
||||
@@ -16,6 +48,45 @@ export type SoldOutBuckets = AdminDashboardSoldOutBuckets;
|
||||
|
||||
type MoneyFormatter = (minor: number, currency: string | null) => string;
|
||||
|
||||
function usageBarFill(pct: number): string {
|
||||
if (pct >= 95) {
|
||||
return DASHBOARD_CHART_COLORS.rose;
|
||||
}
|
||||
if (pct >= 80) {
|
||||
return DASHBOARD_CHART_COLORS.warning;
|
||||
}
|
||||
return DASHBOARD_CHART_COLORS.primary;
|
||||
}
|
||||
|
||||
function capUsageFill(pct: number): string {
|
||||
if (pct >= 90) {
|
||||
return "var(--destructive)";
|
||||
}
|
||||
if (pct >= 70) {
|
||||
return DASHBOARD_CHART_COLORS.warning;
|
||||
}
|
||||
return DASHBOARD_CHART_COLORS.primary;
|
||||
}
|
||||
|
||||
function settlementBarColor(status: string): string {
|
||||
switch (status) {
|
||||
case "pending_review":
|
||||
return DASHBOARD_CHART_COLORS.warning;
|
||||
case "approved":
|
||||
return "oklch(0.62 0.14 230)";
|
||||
case "paid":
|
||||
case "completed":
|
||||
return DASHBOARD_CHART_COLORS.success;
|
||||
case "running":
|
||||
return DASHBOARD_CHART_COLORS.primary;
|
||||
case "rejected":
|
||||
case "failed":
|
||||
return DASHBOARD_CHART_COLORS.rose;
|
||||
default:
|
||||
return DASHBOARD_CHART_COLORS.violet;
|
||||
}
|
||||
}
|
||||
|
||||
export function StatCard({
|
||||
label,
|
||||
value,
|
||||
@@ -37,8 +108,8 @@ export function StatCard({
|
||||
: "bg-primary text-primary-foreground";
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border/80 bg-card p-5 shadow-sm">
|
||||
<div className="flex gap-4">
|
||||
<Card className="border-border/80 py-0 shadow-sm">
|
||||
<CardContent className="flex gap-4 p-5">
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-11 shrink-0 items-center justify-center rounded-lg shadow-sm",
|
||||
@@ -52,8 +123,8 @@ export function StatCard({
|
||||
<p className="mt-1 text-2xl font-bold tabular-nums tracking-tight text-foreground">{value}</p>
|
||||
{hint ? <div className="mt-2 text-xs text-muted-foreground">{hint}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,23 +143,49 @@ export function CapUsageBar({
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const pct = Math.min(100, Math.max(0, usagePct));
|
||||
const fill = capUsageFill(pct);
|
||||
const chartConfig = useMemo(
|
||||
() => buildUsageBarConfig(t("riskCapUsage")),
|
||||
[t],
|
||||
);
|
||||
const radialData = useMemo(() => [{ usage: pct, fill }], [pct, fill]);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-end justify-between gap-2">
|
||||
<span className="text-sm font-medium text-foreground">{t("riskCapUsage")}</span>
|
||||
<span className="text-2xl font-bold tabular-nums text-foreground">{pct.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="h-3 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-all duration-500",
|
||||
pct >= 90 ? "bg-destructive" : pct >= 70 ? "bg-amber-500" : "bg-primary",
|
||||
)}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs tabular-nums text-muted-foreground">
|
||||
<div className="space-y-4">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="mx-auto aspect-square h-[180px] w-full max-w-[200px]"
|
||||
>
|
||||
<RadialBarChart
|
||||
data={radialData}
|
||||
startAngle={90}
|
||||
endAngle={-270}
|
||||
innerRadius="72%"
|
||||
outerRadius="100%"
|
||||
>
|
||||
<PolarAngleAxis type="number" domain={[0, 100]} tick={false} />
|
||||
<RadialBar
|
||||
dataKey="usage"
|
||||
background={{ fill: "var(--muted)" }}
|
||||
cornerRadius={8}
|
||||
fill={fill}
|
||||
/>
|
||||
<Label
|
||||
content={({ viewBox }) => {
|
||||
if (!viewBox || !("cx" in viewBox) || !("cy" in viewBox)) {
|
||||
return null;
|
||||
}
|
||||
const { cx, cy } = viewBox as { cx: number; cy: number };
|
||||
return (
|
||||
<text x={cx} y={cy} textAnchor="middle" dominantBaseline="middle">
|
||||
<tspan className="fill-foreground text-2xl font-bold">{pct.toFixed(1)}%</tspan>
|
||||
</text>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</RadialBarChart>
|
||||
</ChartContainer>
|
||||
<p className="text-center text-xs tabular-nums text-muted-foreground">
|
||||
{t("lockedAndCap", {
|
||||
locked: formatMoney(locked, currency),
|
||||
cap: formatMoney(cap, currency),
|
||||
@@ -110,50 +207,51 @@ export function FinanceStructureChart({
|
||||
const bet = finance.total_bet_minor;
|
||||
const win = finance.total_win_payout_minor;
|
||||
const jackpot = finance.total_jackpot_win_minor;
|
||||
const payout = finance.total_payout_minor;
|
||||
const gross = finance.approx_house_gross_minor;
|
||||
const payout = finance.total_payout_minor;
|
||||
|
||||
const chartConfig = useMemo(
|
||||
() =>
|
||||
buildFinanceStructureConfig({
|
||||
win: t("winPayout"),
|
||||
jackpot: t("jackpotPayout"),
|
||||
gross: t("houseGross"),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
if (bet <= 0) {
|
||||
return <p className="py-8 text-center text-sm text-muted-foreground">{t("noFinanceActivity")}</p>;
|
||||
return <DashboardChartEmpty message={t("noFinanceActivity")} />;
|
||||
}
|
||||
|
||||
const winW = (win / bet) * 100;
|
||||
const jpW = (jackpot / bet) * 100;
|
||||
const grossW = Math.max(0, (gross / bet) * 100);
|
||||
const payoutRate = ((payout / bet) * 100).toFixed(1);
|
||||
|
||||
const segments = [
|
||||
{ key: "win", width: winW, className: "bg-emerald-500", label: t("winPayout"), value: win },
|
||||
{ key: "jackpot", width: jpW, className: "bg-violet-500", label: t("jackpotPayout"), value: jackpot },
|
||||
{ key: "gross", width: grossW, className: "bg-primary", label: t("houseGross"), value: gross },
|
||||
].filter((s) => s.width > 0.05);
|
||||
const chartData = [{ segment: "structure", win, jackpot, gross }];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex h-10 overflow-hidden rounded-lg ring-1 ring-border/60">
|
||||
{segments.map((s) => (
|
||||
<div
|
||||
key={s.key}
|
||||
className={cn("min-w-[2px] transition-all", s.className)}
|
||||
style={{ width: `${s.width}%` }}
|
||||
title={`${s.label}: ${formatMoney(s.value, currency)}`}
|
||||
<ChartContainer config={chartConfig} className="aspect-auto h-12 w-full">
|
||||
<BarChart
|
||||
accessibilityLayer
|
||||
layout="vertical"
|
||||
data={chartData}
|
||||
margin={{ top: 0, right: 0, bottom: 0, left: 0 }}
|
||||
>
|
||||
<XAxis type="number" hide domain={[0, bet]} />
|
||||
<YAxis type="category" dataKey="segment" hide width={0} />
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent formatter={(value, _name) => formatMoney(Number(value), currency)} />
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Bar dataKey="win" stackId="structure" fill="var(--color-win)" radius={4} />
|
||||
<Bar dataKey="jackpot" stackId="structure" fill="var(--color-jackpot)" radius={4} />
|
||||
<Bar dataKey="gross" stackId="structure" fill="var(--color-gross)" radius={4} />
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
{t("payoutRateOfBet", { rate: payoutRate })}
|
||||
</p>
|
||||
<ul className="grid gap-2 sm:grid-cols-3">
|
||||
{segments.map((s) => (
|
||||
<li key={s.key} className="flex items-center gap-2 rounded-md border border-border/60 bg-muted/30 px-3 py-2">
|
||||
<span className={cn("size-2.5 shrink-0 rounded-sm", s.className)} />
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-muted-foreground">{s.label}</p>
|
||||
<p className="truncate text-sm font-semibold tabular-nums">{formatMoney(s.value, currency)}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -171,190 +269,215 @@ export function PayoutCompositionChart({
|
||||
const jackpot = finance.total_jackpot_win_minor;
|
||||
const total = win + jackpot;
|
||||
|
||||
const chartConfig = useMemo(
|
||||
() =>
|
||||
buildPayoutPieConfig({
|
||||
win: t("winPayout"),
|
||||
jackpot: t("jackpotPayout"),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
if (total <= 0) {
|
||||
return <p className="py-8 text-center text-sm text-muted-foreground">{t("noPayoutYet")}</p>;
|
||||
return <DashboardChartEmpty message={t("noPayoutYet")} />;
|
||||
}
|
||||
|
||||
const winPct = (win / total) * 100;
|
||||
const winColor = "oklch(0.62 0.17 162)";
|
||||
const jackpotColor = "oklch(0.56 0.22 303)";
|
||||
const items = [
|
||||
{ label: t("winPayout"), value: win, pct: winPct, className: "bg-emerald-500", color: winColor },
|
||||
{
|
||||
label: t("jackpotPayout"),
|
||||
value: jackpot,
|
||||
pct: 100 - winPct,
|
||||
className: "bg-violet-500",
|
||||
color: jackpotColor,
|
||||
},
|
||||
const pieData = [
|
||||
{ key: "win", value: win, fill: "var(--color-win)" },
|
||||
{ key: "jackpot", value: jackpot, fill: "var(--color-jackpot)" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
<div
|
||||
className="relative mx-auto size-36 shrink-0 rounded-full"
|
||||
style={{
|
||||
background: `conic-gradient(from -90deg, ${winColor} 0deg ${winPct * 3.6}deg, ${jackpotColor} ${winPct * 3.6}deg 360deg)`,
|
||||
mask: "radial-gradient(farthest-side, transparent 58%, #000 59%)",
|
||||
WebkitMask: "radial-gradient(farthest-side, transparent 58%, #000 59%)",
|
||||
}}
|
||||
/>
|
||||
<ul className="min-w-0 flex-1 space-y-3">
|
||||
{items.map((item) => (
|
||||
<li key={item.label}>
|
||||
<div className="mb-1 flex justify-between gap-2 text-sm">
|
||||
<span className="flex items-center gap-2 text-muted-foreground">
|
||||
<span className={cn("size-2.5 rounded-sm", item.className)} />
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="font-medium tabular-nums">{item.pct.toFixed(1)}%</span>
|
||||
</div>
|
||||
<p className="text-sm font-semibold tabular-nums">{formatMoney(item.value, currency)}</p>
|
||||
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full"
|
||||
style={{ width: `${item.pct}%`, background: item.color }}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<ChartContainer config={chartConfig} className="mx-auto aspect-square h-[220px] w-full max-w-[280px]">
|
||||
<PieChart>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
nameKey="key"
|
||||
formatter={(value) => formatMoney(Number(value), currency)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Pie
|
||||
data={pieData}
|
||||
dataKey="value"
|
||||
nameKey="key"
|
||||
innerRadius="58%"
|
||||
outerRadius="82%"
|
||||
paddingAngle={2}
|
||||
>
|
||||
{pieData.map((entry) => (
|
||||
<Cell key={entry.key} fill={entry.fill} />
|
||||
))}
|
||||
</Pie>
|
||||
<ChartLegend content={<ChartLegendContent nameKey="key" />} />
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export function HotUsageBars({ rows }: { rows: AdminRiskPoolRow[] }): ReactElement {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const chartConfig = useMemo(() => buildUsageBarConfig(t("riskCapUsage")), [t]);
|
||||
|
||||
const chartData = useMemo(
|
||||
() =>
|
||||
rows.map((row) => {
|
||||
const pct = Math.min(100, Math.max(0, (row.usage_ratio ?? 0) * 100));
|
||||
return {
|
||||
number: row.normalized_number.trim(),
|
||||
usage: pct,
|
||||
fill: usageBarFill(pct),
|
||||
};
|
||||
}),
|
||||
[rows],
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return <p className="py-10 text-center text-sm text-muted-foreground">{t("noPoolData")}</p>;
|
||||
return <DashboardChartEmpty message={t("noPoolData")} />;
|
||||
}
|
||||
|
||||
const chartHeight = Math.min(420, Math.max(160, rows.length * 32 + 48));
|
||||
|
||||
return (
|
||||
<ul className="space-y-2.5">
|
||||
{rows.map((row) => {
|
||||
const pct = Math.min(100, Math.max(0, (row.usage_ratio ?? 0) * 100));
|
||||
return (
|
||||
<li key={row.normalized_number}>
|
||||
<div className="mb-1 flex items-center justify-between gap-2 text-xs">
|
||||
<span className="truncate font-mono font-medium text-foreground">
|
||||
{row.normalized_number.trim()}
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground">{pct.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-all",
|
||||
pct >= 95 ? "bg-destructive" : pct >= 80 ? "bg-amber-500" : "bg-primary",
|
||||
)}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="aspect-auto w-full"
|
||||
style={{ height: chartHeight }}
|
||||
>
|
||||
<BarChart
|
||||
accessibilityLayer
|
||||
layout="vertical"
|
||||
data={chartData}
|
||||
margin={{ top: 4, right: 12, bottom: 4, left: 4 }}
|
||||
>
|
||||
<XAxis type="number" domain={[0, 100]} hide />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="number"
|
||||
width={72}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tick={{ fontSize: 11, fontFamily: "var(--font-mono)" }}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
formatter={(value) => `${Number(value).toFixed(1)}%`}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Bar dataKey="usage" radius={4} barSize={14}>
|
||||
{chartData.map((entry) => (
|
||||
<Cell key={entry.number} fill={entry.fill} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export function SoldOutRing({ buckets }: { buckets: SoldOutBuckets }): ReactElement {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const entries: { key: keyof SoldOutBuckets; label: string; color: string; swatch: string }[] = [
|
||||
{ key: "d4", label: t("soldOutBuckets.d4"), color: "oklch(0.52 0.19 264)", swatch: "bg-blue-600" },
|
||||
{ key: "d3", label: t("soldOutBuckets.d3"), color: "oklch(0.62 0.17 162)", swatch: "bg-emerald-500" },
|
||||
{ key: "d2", label: t("soldOutBuckets.d2"), color: "oklch(0.72 0.16 75)", swatch: "bg-amber-500" },
|
||||
{ key: "special", label: t("soldOutBuckets.special"), color: "oklch(0.56 0.22 303)", swatch: "bg-violet-500" },
|
||||
{ key: "other", label: t("soldOutBuckets.other"), color: "oklch(0.58 0.2 25)", swatch: "bg-rose-500" },
|
||||
];
|
||||
const total = entries.reduce((s, e) => s + buckets[e.key], 0);
|
||||
|
||||
const chartConfig = useMemo(
|
||||
() =>
|
||||
buildSoldOutPieConfig({
|
||||
d4: t("soldOutBuckets.d4"),
|
||||
d3: t("soldOutBuckets.d3"),
|
||||
d2: t("soldOutBuckets.d2"),
|
||||
special: t("soldOutBuckets.special"),
|
||||
other: t("soldOutBuckets.other"),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
const entries: (keyof SoldOutBuckets)[] = ["d4", "d3", "d2", "special", "other"];
|
||||
const total = entries.reduce((s, key) => s + buckets[key], 0);
|
||||
|
||||
if (total === 0) {
|
||||
return <p className="py-10 text-center text-sm text-muted-foreground">{t("noSoldOutNumbers")}</p>;
|
||||
return <DashboardChartEmpty message={t("noSoldOutNumbers")} />;
|
||||
}
|
||||
|
||||
let acc = 0;
|
||||
const parts = entries
|
||||
.filter((e) => buckets[e.key] > 0)
|
||||
.map((e) => {
|
||||
const frac = buckets[e.key] / total;
|
||||
const start = acc;
|
||||
acc += frac;
|
||||
return { ...e, frac, start };
|
||||
});
|
||||
|
||||
const gradientStops =
|
||||
parts.length === 1
|
||||
? `${parts[0].color} 0deg 360deg`
|
||||
: parts
|
||||
.map((p) => {
|
||||
const a0 = p.start * 360;
|
||||
const a1 = (p.start + p.frac) * 360;
|
||||
return `${p.color} ${a0}deg ${a1}deg`;
|
||||
})
|
||||
.join(", ");
|
||||
const pieData = entries
|
||||
.filter((key) => buckets[key] > 0)
|
||||
.map((key) => ({
|
||||
key,
|
||||
value: buckets[key],
|
||||
fill: `var(--color-${key})`,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-stretch gap-4 sm:flex-row sm:items-center">
|
||||
<div className="relative mx-auto size-40 shrink-0">
|
||||
<div
|
||||
className="size-full rounded-full"
|
||||
style={{
|
||||
background: `conic-gradient(from -90deg, ${gradientStops})`,
|
||||
mask: "radial-gradient(farthest-side, transparent 58%, #000 59%)",
|
||||
WebkitMask: "radial-gradient(farthest-side, transparent 58%, #000 59%)",
|
||||
}}
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
|
||||
<p className="text-3xl font-bold tabular-nums">{total}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("soldOutTotal")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="min-w-0 flex-1 space-y-2">
|
||||
{entries.map((e) => {
|
||||
const count = buckets[e.key];
|
||||
const pct = total > 0 ? (count / total) * 100 : 0;
|
||||
return (
|
||||
<li key={e.key}>
|
||||
<div className="mb-1 flex justify-between text-sm">
|
||||
<span className="flex items-center gap-2 text-muted-foreground">
|
||||
<span className={cn("size-2.5 rounded-sm", e.swatch)} />
|
||||
{e.label}
|
||||
</span>
|
||||
<span className="font-medium tabular-nums">
|
||||
{count}
|
||||
<span className="ml-1 text-xs font-normal text-muted-foreground">({pct.toFixed(0)}%)</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<div className="h-full rounded-full" style={{ width: `${pct}%`, background: e.color }} />
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
<ChartContainer config={chartConfig} className="mx-auto aspect-square h-[240px] w-full max-w-[320px]">
|
||||
<PieChart>
|
||||
<ChartTooltip content={<ChartTooltipContent nameKey="key" />} />
|
||||
<Pie
|
||||
data={pieData}
|
||||
dataKey="value"
|
||||
nameKey="key"
|
||||
innerRadius="58%"
|
||||
outerRadius="82%"
|
||||
paddingAngle={2}
|
||||
>
|
||||
{pieData.map((entry) => (
|
||||
<Cell key={entry.key} fill={entry.fill} />
|
||||
))}
|
||||
<Label
|
||||
content={({ viewBox }) => {
|
||||
if (!viewBox || !("cx" in viewBox) || !("cy" in viewBox)) {
|
||||
return null;
|
||||
}
|
||||
const { cx, cy } = viewBox as { cx: number; cy: number };
|
||||
return (
|
||||
<text x={cx} y={cy} textAnchor="middle" dominantBaseline="middle">
|
||||
<tspan className="fill-foreground text-3xl font-bold">{total}</tspan>
|
||||
<tspan x={cx} dy="1.4em" className="fill-muted-foreground text-xs">
|
||||
{t("soldOutTotal")}
|
||||
</tspan>
|
||||
</text>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Pie>
|
||||
<ChartLegend content={<ChartLegendContent nameKey="key" />} />
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export function ResultBatchProgress({ draw }: { draw: AdminDashboardDrawPanel }): ReactElement {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const { total, pending_review, published } = draw.result_batch_counts;
|
||||
const pendingW = total > 0 ? (pending_review / total) * 100 : 0;
|
||||
const publishedW = total > 0 ? (published / total) * 100 : 0;
|
||||
const otherW = Math.max(0, 100 - pendingW - publishedW);
|
||||
const other = Math.max(0, total - pending_review - published);
|
||||
|
||||
const chartConfig = useMemo(
|
||||
() =>
|
||||
buildBatchProgressConfig({
|
||||
pending: t("batchPending"),
|
||||
published: t("batchPublished"),
|
||||
other: t("batchOther"),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
const chartData = [{ row: "batches", pending: pending_review, published, other }];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex h-3 overflow-hidden rounded-full bg-muted">
|
||||
{pendingW > 0 ? (
|
||||
<div className="bg-amber-500" style={{ width: `${pendingW}%` }} title={t("batchPending")} />
|
||||
) : null}
|
||||
{publishedW > 0 ? (
|
||||
<div className="bg-emerald-600" style={{ width: `${publishedW}%` }} title={t("batchPublished")} />
|
||||
) : null}
|
||||
{otherW > 0 ? <div className="bg-muted-foreground/30" style={{ width: `${otherW}%` }} /> : null}
|
||||
</div>
|
||||
<ChartContainer config={chartConfig} className="aspect-auto h-10 w-full">
|
||||
<BarChart
|
||||
accessibilityLayer
|
||||
layout="vertical"
|
||||
data={chartData}
|
||||
margin={{ top: 0, right: 0, bottom: 0, left: 0 }}
|
||||
>
|
||||
<XAxis type="number" hide domain={[0, Math.max(total, 1)]} />
|
||||
<YAxis type="category" dataKey="row" hide width={0} />
|
||||
<Bar dataKey="pending" stackId="batch" fill="var(--color-pending)" radius={4} />
|
||||
<Bar dataKey="published" stackId="batch" fill="var(--color-published)" radius={4} />
|
||||
<Bar dataKey="other" stackId="batch" fill="var(--color-other)" radius={4} />
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
<div className="grid grid-cols-3 gap-2 text-center">
|
||||
<div className="rounded-lg border border-border/60 bg-muted/20 px-2 py-3">
|
||||
<p className="text-2xl font-bold tabular-nums text-amber-600">{pending_review}</p>
|
||||
@@ -379,54 +502,68 @@ export function SettlementStatusChart({
|
||||
finance: AdminDrawFinanceSummaryData;
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const batches = finance.settlement_batches ?? [];
|
||||
const settlementBatches = finance.settlement_batches;
|
||||
|
||||
if (batches.length === 0) {
|
||||
return <p className="py-6 text-center text-sm text-muted-foreground">{t("noSettlementBatches")}</p>;
|
||||
}
|
||||
|
||||
const counts = new Map<string, number>();
|
||||
for (const b of batches) {
|
||||
counts.set(b.status, (counts.get(b.status) ?? 0) + 1);
|
||||
}
|
||||
const entries = [...counts.entries()].sort((a, b) => b[1] - a[1]);
|
||||
const max = Math.max(...entries.map((e) => e[1]));
|
||||
|
||||
const barTone = (status: string): string => {
|
||||
switch (status) {
|
||||
case "pending_review":
|
||||
return "bg-amber-500";
|
||||
case "approved":
|
||||
return "bg-sky-500";
|
||||
case "paid":
|
||||
case "completed":
|
||||
return "bg-emerald-600";
|
||||
case "running":
|
||||
return "bg-blue-500";
|
||||
case "rejected":
|
||||
case "failed":
|
||||
return "bg-rose-500";
|
||||
default:
|
||||
return "bg-violet-500";
|
||||
const entries = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const b of settlementBatches ?? []) {
|
||||
counts.set(b.status, (counts.get(b.status) ?? 0) + 1);
|
||||
}
|
||||
};
|
||||
return [...counts.entries()].sort((a, b) => b[1] - a[1]);
|
||||
}, [settlementBatches]);
|
||||
|
||||
const chartConfig = useMemo(
|
||||
(): ChartConfig =>
|
||||
buildSettlementBarConfig(
|
||||
entries.map(([status]) => ({
|
||||
status,
|
||||
label: status,
|
||||
color: settlementBarColor(status),
|
||||
})),
|
||||
),
|
||||
[entries],
|
||||
);
|
||||
|
||||
if (!settlementBatches || settlementBatches.length === 0) {
|
||||
return <DashboardChartEmpty message={t("noSettlementBatches")} />;
|
||||
}
|
||||
|
||||
const chartData = entries.map(([status, count]) => ({
|
||||
status,
|
||||
count,
|
||||
fill: settlementBarColor(status),
|
||||
}));
|
||||
|
||||
const chartHeight = Math.min(360, Math.max(140, entries.length * 40 + 40));
|
||||
|
||||
return (
|
||||
<ul className="space-y-3">
|
||||
{entries.map(([status, count]) => (
|
||||
<li key={status}>
|
||||
<div className="mb-1 flex items-center justify-between gap-2">
|
||||
<AdminStatusBadge status={status}>{status}</AdminStatusBadge>
|
||||
<span className="text-sm font-medium tabular-nums">{count}</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={cn("h-full rounded-full transition-all", barTone(status))}
|
||||
style={{ width: `${max > 0 ? (count / max) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="aspect-auto w-full"
|
||||
style={{ height: chartHeight }}
|
||||
>
|
||||
<BarChart
|
||||
accessibilityLayer
|
||||
layout="vertical"
|
||||
data={chartData}
|
||||
margin={{ top: 4, right: 12, bottom: 4, left: 8 }}
|
||||
>
|
||||
<XAxis type="number" hide />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="status"
|
||||
width={108}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tick={{ fontSize: 11 }}
|
||||
/>
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Bar dataKey="count" radius={4} barSize={16}>
|
||||
{chartData.map((entry) => (
|
||||
<Cell key={entry.status} fill={entry.fill} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user