refactor(layout, i18n, admin): 优化布局结构与多语言支持
调整 AdminShell 组件的子组件顺序,提升代码可读性。更新 admin-breadcrumb 组件,简化导航标签翻译逻辑,确保多语言支持的一致性。重构 admin-language-switcher 组件,优化语言切换的用户体验,增强界面交互性。更新多语言配置,新增登录界面的副标题,提升用户体验。
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertTriangle, ArrowRightIcon, CheckCircle2, ChevronRightIcon } from "lucide-react";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
@@ -18,6 +20,7 @@ import {
|
||||
} from "recharts";
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
@@ -87,18 +90,137 @@ function settlementBarColor(status: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
type DashboardKpiAccent = "primary" | "destructive" | "muted";
|
||||
|
||||
function kpiAccentClass(accent: DashboardKpiAccent): string {
|
||||
switch (accent) {
|
||||
case "destructive":
|
||||
return "bg-destructive/10 text-destructive";
|
||||
case "muted":
|
||||
return "bg-muted text-muted-foreground";
|
||||
default:
|
||||
return "bg-primary/10 text-primary";
|
||||
}
|
||||
}
|
||||
|
||||
/** 财务概览区紧凑 KPI,避免 StatCard 在窄栅格内撑破布局 */
|
||||
export function DashboardKpiCard({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
icon,
|
||||
accent = "primary",
|
||||
sparklineValues,
|
||||
deltaLabel,
|
||||
}: {
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
hint?: ReactNode;
|
||||
icon: ReactNode;
|
||||
accent?: DashboardKpiAccent;
|
||||
sparklineValues?: number[];
|
||||
deltaLabel?: ReactNode;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<div className="flex h-full min-w-0 flex-col rounded-xl border border-border/60 bg-card p-4">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-10 shrink-0 items-center justify-center rounded-lg",
|
||||
kpiAccentClass(accent),
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium text-muted-foreground">{label}</p>
|
||||
<p className="mt-1 truncate text-xl font-bold tabular-nums tracking-tight text-foreground">
|
||||
{value}
|
||||
</p>
|
||||
{deltaLabel ? <div className="mt-1 text-xs font-medium tabular-nums">{deltaLabel}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
{sparklineValues && sparklineValues.length >= 2 ? (
|
||||
<div className="mt-3 flex justify-end">
|
||||
<MiniSparkline
|
||||
values={sparklineValues}
|
||||
strokeClass={
|
||||
accent === "destructive"
|
||||
? "stroke-destructive"
|
||||
: accent === "muted"
|
||||
? "stroke-muted-foreground"
|
||||
: "stroke-primary"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{hint ? (
|
||||
<p className="mt-2 line-clamp-2 text-[11px] leading-snug text-muted-foreground">{hint}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MiniSparkline({
|
||||
values,
|
||||
strokeClass,
|
||||
}: {
|
||||
values: number[];
|
||||
strokeClass: string;
|
||||
}): ReactElement | null {
|
||||
if (values.length < 2) {
|
||||
return null;
|
||||
}
|
||||
const width = 88;
|
||||
const height = 32;
|
||||
const max = Math.max(...values, 1);
|
||||
const min = Math.min(...values, 0);
|
||||
const range = Math.max(max - min, 1);
|
||||
const points = values
|
||||
.map((v, i) => {
|
||||
const x = (i / (values.length - 1)) * width;
|
||||
const y = height - ((v - min) / range) * (height - 4) - 2;
|
||||
return `${x},${y}`;
|
||||
})
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className="h-8 w-[5.5rem] shrink-0"
|
||||
aria-hidden
|
||||
>
|
||||
<polyline
|
||||
fill="none"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
points={points}
|
||||
className={strokeClass}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatCard({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
icon,
|
||||
accent = "primary",
|
||||
href,
|
||||
sparklineValues,
|
||||
deltaLabel,
|
||||
}: {
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
hint?: ReactNode;
|
||||
icon: ReactNode;
|
||||
accent?: "primary" | "destructive" | "muted";
|
||||
/** 整张卡片可点击跳转 */
|
||||
href?: string;
|
||||
sparklineValues?: number[];
|
||||
deltaLabel?: ReactNode;
|
||||
}): ReactElement {
|
||||
const accentClass =
|
||||
accent === "destructive"
|
||||
@@ -107,9 +229,15 @@ export function StatCard({
|
||||
? "bg-muted text-foreground"
|
||||
: "bg-primary text-primary-foreground";
|
||||
|
||||
return (
|
||||
<Card className="border-border/80 py-0 shadow-sm">
|
||||
<CardContent className="flex gap-4 p-5">
|
||||
const card = (
|
||||
<Card
|
||||
className={cn(
|
||||
"flex h-full flex-col border-border/80 py-0 shadow-sm transition-colors",
|
||||
href &&
|
||||
"group-hover/stat border-primary/30 bg-muted/15 shadow-md",
|
||||
)}
|
||||
>
|
||||
<CardContent className="flex flex-1 items-start gap-4 p-5">
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-11 shrink-0 items-center justify-center rounded-lg shadow-sm",
|
||||
@@ -118,14 +246,292 @@ export function StatCard({
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<p className="text-sm font-medium text-muted-foreground">{label}</p>
|
||||
<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}
|
||||
<p className="mt-1 text-2xl font-bold tabular-nums tracking-tight text-foreground">
|
||||
{value}
|
||||
</p>
|
||||
{deltaLabel ? (
|
||||
<p className="mt-1 text-xs font-medium tabular-nums">{deltaLabel}</p>
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
"mt-auto min-h-10 pt-2 text-xs leading-snug",
|
||||
hint
|
||||
? href
|
||||
? "font-medium text-primary group-hover/stat:underline"
|
||||
: "text-muted-foreground"
|
||||
: "text-transparent",
|
||||
)}
|
||||
>
|
||||
{hint ?? "\u00a0"}
|
||||
</div>
|
||||
</div>
|
||||
{sparklineValues ? (
|
||||
<MiniSparkline
|
||||
values={sparklineValues}
|
||||
strokeClass={
|
||||
accent === "destructive"
|
||||
? "stroke-destructive"
|
||||
: accent === "muted"
|
||||
? "stroke-muted-foreground"
|
||||
: "stroke-primary"
|
||||
}
|
||||
/>
|
||||
) : href ? (
|
||||
<ChevronRightIcon
|
||||
className="mt-0.5 size-4 shrink-0 text-muted-foreground/60 transition group-hover/stat:text-primary"
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const shellClass = "flex h-full min-h-0 rounded-2xl";
|
||||
|
||||
if (!href) {
|
||||
return <div className={shellClass}>{card}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
shellClass,
|
||||
"group/stat outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
)}
|
||||
>
|
||||
{card}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
type DashboardPanelAccent = "primary" | "destructive" | "warning" | "muted";
|
||||
|
||||
function panelAccentClass(accent: DashboardPanelAccent): string {
|
||||
switch (accent) {
|
||||
case "destructive":
|
||||
return "bg-destructive/10 text-destructive";
|
||||
case "warning":
|
||||
return "bg-amber-500/15 text-amber-700 dark:text-amber-400";
|
||||
case "muted":
|
||||
return "bg-muted text-muted-foreground";
|
||||
default:
|
||||
return "bg-primary/10 text-primary";
|
||||
}
|
||||
}
|
||||
|
||||
/** 仪表盘 KPI:整卡可点,主指标 + 可选底部可视化 */
|
||||
export function DashboardPanelCard({
|
||||
href,
|
||||
icon,
|
||||
title,
|
||||
value,
|
||||
subtitle,
|
||||
actionLabel,
|
||||
accent = "primary",
|
||||
loading = false,
|
||||
highlight = false,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
title: string;
|
||||
value: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
actionLabel: string;
|
||||
icon: ReactNode;
|
||||
accent?: DashboardPanelAccent;
|
||||
loading?: boolean;
|
||||
/** 有异常/待办时强调边框 */
|
||||
highlight?: boolean;
|
||||
children?: ReactNode;
|
||||
}): ReactElement {
|
||||
const hasFooter = children != null;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
aria-label={`${title},${actionLabel}`}
|
||||
className="group/panel flex h-full min-w-0 w-full rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<Card
|
||||
className={cn(
|
||||
"admin-list-card flex h-full min-w-0 w-full flex-col py-0 transition-all duration-200",
|
||||
"hover:border-primary/30 hover:shadow-md",
|
||||
highlight && "border-amber-400/50 ring-1 ring-amber-400/25 dark:border-amber-500/40",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-1 flex-col p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-10 shrink-0 items-center justify-center rounded-xl [&_svg]:size-[1.125rem]",
|
||||
panelAccentClass(accent),
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<span
|
||||
className="flex size-8 shrink-0 items-center justify-center rounded-full text-muted-foreground/70 transition-colors group-hover/panel:bg-primary/10 group-hover/panel:text-primary"
|
||||
title={actionLabel}
|
||||
>
|
||||
<ArrowRightIcon
|
||||
className="size-4 transition-transform group-hover/panel:translate-x-0.5"
|
||||
aria-hidden
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 min-w-0">
|
||||
<p className="text-xs font-medium text-muted-foreground">{title}</p>
|
||||
{loading ? (
|
||||
<Skeleton className="mt-2 h-8 w-24 rounded-md" />
|
||||
) : (
|
||||
<p className="mt-1 text-2xl font-bold tabular-nums leading-none tracking-tight text-foreground">
|
||||
{value}
|
||||
</p>
|
||||
)}
|
||||
{subtitle && !loading ? (
|
||||
<p className="mt-2 line-clamp-2 text-xs leading-relaxed text-muted-foreground">
|
||||
{subtitle}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{hasFooter ? (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-4 flex min-h-[3.25rem] items-center justify-center",
|
||||
loading && "items-stretch",
|
||||
)}
|
||||
>
|
||||
{loading ? (
|
||||
<Skeleton className="h-[3.25rem] w-full rounded-lg" />
|
||||
) : (
|
||||
<div className="w-full border-t border-dashed border-border/60 pt-3">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/** 异常转账 KPI 底部:待办提示或正常态 */
|
||||
export function AbnormalTransferPanelFooter({
|
||||
total,
|
||||
walletPermission = true,
|
||||
}: {
|
||||
total: number | null;
|
||||
walletPermission?: boolean;
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation("dashboard");
|
||||
|
||||
if (!walletPermission) {
|
||||
return (
|
||||
<p className="rounded-lg bg-muted/40 px-3 py-2.5 text-center text-[11px] leading-snug text-muted-foreground ring-1 ring-border/50">
|
||||
{t("warnings.walletPermission")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (total == null) {
|
||||
return (
|
||||
<p className="rounded-lg bg-muted/40 px-3 py-2.5 text-center text-[11px] text-muted-foreground ring-1 ring-border/50">
|
||||
{t("states.noData", { ns: "common" })}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (total > 0) {
|
||||
return (
|
||||
<div className="flex items-start gap-2.5 rounded-lg bg-amber-500/10 px-3 py-2.5 ring-1 ring-amber-500/20">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-600 dark:text-amber-400" aria-hidden />
|
||||
<div className="min-w-0 text-left">
|
||||
<p className="text-xs font-semibold text-amber-900 dark:text-amber-200">
|
||||
{t("abnormalTransferPending", { count: total })}
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] leading-snug text-amber-800/90 dark:text-amber-300/90">
|
||||
{t("abnormalTransferAction")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-emerald-500/8 px-3 py-2.5 ring-1 ring-emerald-500/15">
|
||||
<CheckCircle2 className="size-4 shrink-0 text-emerald-600 dark:text-emerald-400" aria-hidden />
|
||||
<p className="text-xs font-medium text-emerald-800 dark:text-emerald-300">
|
||||
{t("abnormalTransferAllClear")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 派彩 KPI 底部:投注/中奖/奖池拆分,有派彩时再附饼图 */
|
||||
export function PayoutPanelSnapshot({
|
||||
finance,
|
||||
formatMoney,
|
||||
}: {
|
||||
finance: AdminDrawFinanceSummaryData;
|
||||
formatMoney: MoneyFormatter;
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const currency = finance.currency_code;
|
||||
const bet = finance.total_bet_minor;
|
||||
const win = finance.total_win_payout_minor;
|
||||
const jackpot = finance.total_jackpot_win_minor;
|
||||
const hasPayout = win + jackpot > 0;
|
||||
|
||||
if (bet <= 0 && !hasPayout) {
|
||||
return <DashboardChartEmpty message={t("noFinanceActivity")} compact />;
|
||||
}
|
||||
|
||||
const cells = [
|
||||
{ key: "bet", label: t("currentDrawBetTotal"), amount: bet, emphasize: bet > 0 },
|
||||
{ key: "win", label: t("winPayout"), amount: win, emphasize: win > 0 },
|
||||
{ key: "jackpot", label: t("jackpotPayout"), amount: jackpot, emphasize: jackpot > 0 },
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-3 gap-2 text-center">
|
||||
{cells.map((cell) => (
|
||||
<div
|
||||
key={cell.key}
|
||||
className={cn(
|
||||
"rounded-lg px-1.5 py-2 ring-1",
|
||||
cell.emphasize
|
||||
? "bg-primary/6 ring-primary/15"
|
||||
: "bg-muted/30 ring-border/50",
|
||||
)}
|
||||
>
|
||||
<p className="text-[10px] leading-tight text-muted-foreground">{cell.label}</p>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-1 text-[11px] font-bold tabular-nums leading-tight",
|
||||
cell.emphasize ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{formatMoney(cell.amount, currency)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{hasPayout ? (
|
||||
<PayoutCompositionChart finance={finance} formatMoney={formatMoney} compact />
|
||||
) : (
|
||||
<p className="rounded-lg bg-muted/25 px-2 py-2 text-center text-[11px] text-muted-foreground ring-1 ring-border/40">
|
||||
{t("noPayoutYet")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CapUsageBar({
|
||||
@@ -134,12 +540,15 @@ export function CapUsageBar({
|
||||
usagePct,
|
||||
formatMoney,
|
||||
currency,
|
||||
compact = false,
|
||||
}: {
|
||||
locked: number;
|
||||
cap: number;
|
||||
usagePct: number;
|
||||
formatMoney: MoneyFormatter;
|
||||
currency: string | null;
|
||||
/** 嵌入 DashboardPanelCard 时隐藏底部说明、缩小图表 */
|
||||
compact?: boolean;
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const pct = Math.min(100, Math.max(0, usagePct));
|
||||
@@ -150,6 +559,24 @@ export function CapUsageBar({
|
||||
);
|
||||
const radialData = useMemo(() => [{ usage: pct, fill }], [pct, fill]);
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div
|
||||
className="h-2 overflow-hidden rounded-full bg-muted"
|
||||
role="progressbar"
|
||||
aria-valuenow={pct}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label={t("riskCapUsage")}
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full transition-[width] duration-500"
|
||||
style={{ width: `${pct}%`, backgroundColor: fill }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<ChartContainer
|
||||
@@ -178,7 +605,9 @@ export function CapUsageBar({
|
||||
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>
|
||||
<tspan className="fill-foreground text-2xl font-bold">
|
||||
{pct.toFixed(1)}%
|
||||
</tspan>
|
||||
</text>
|
||||
);
|
||||
}}
|
||||
@@ -240,7 +669,7 @@ export function FinanceStructureChart({
|
||||
<YAxis type="category" dataKey="segment" hide width={0} />
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent formatter={(value, _name) => formatMoney(Number(value), currency)} />
|
||||
<ChartTooltipContent formatter={(value) => formatMoney(Number(value), currency)} />
|
||||
}
|
||||
/>
|
||||
<Bar dataKey="win" stackId="structure" fill="var(--color-win)" radius={4} />
|
||||
@@ -259,9 +688,11 @@ export function FinanceStructureChart({
|
||||
export function PayoutCompositionChart({
|
||||
finance,
|
||||
formatMoney,
|
||||
compact = false,
|
||||
}: {
|
||||
finance: AdminDrawFinanceSummaryData;
|
||||
formatMoney: MoneyFormatter;
|
||||
compact?: boolean;
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const currency = finance.currency_code;
|
||||
@@ -279,7 +710,7 @@ export function PayoutCompositionChart({
|
||||
);
|
||||
|
||||
if (total <= 0) {
|
||||
return <DashboardChartEmpty message={t("noPayoutYet")} />;
|
||||
return <DashboardChartEmpty message={t("noPayoutYet")} compact={compact} />;
|
||||
}
|
||||
|
||||
const pieData = [
|
||||
@@ -288,7 +719,13 @@ export function PayoutCompositionChart({
|
||||
];
|
||||
|
||||
return (
|
||||
<ChartContainer config={chartConfig} className="mx-auto aspect-square h-[220px] w-full max-w-[280px]">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className={cn(
|
||||
"mx-auto aspect-square w-full",
|
||||
compact ? "h-[72px] max-w-[88px]" : "h-[220px] max-w-[280px]",
|
||||
)}
|
||||
>
|
||||
<PieChart>
|
||||
<ChartTooltip
|
||||
content={
|
||||
@@ -310,13 +747,21 @@ export function PayoutCompositionChart({
|
||||
<Cell key={entry.key} fill={entry.fill} />
|
||||
))}
|
||||
</Pie>
|
||||
<ChartLegend content={<ChartLegendContent nameKey="key" />} />
|
||||
{compact ? null : (
|
||||
<ChartLegend content={<ChartLegendContent nameKey="key" />} />
|
||||
)}
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export function HotUsageBars({ rows }: { rows: AdminRiskPoolRow[] }): ReactElement {
|
||||
export function HotUsageBars({
|
||||
rows,
|
||||
compact = false,
|
||||
}: {
|
||||
rows: AdminRiskPoolRow[];
|
||||
compact?: boolean;
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const chartConfig = useMemo(() => buildUsageBarConfig(t("riskCapUsage")), [t]);
|
||||
|
||||
@@ -337,7 +782,9 @@ export function HotUsageBars({ rows }: { rows: AdminRiskPoolRow[] }): ReactEleme
|
||||
return <DashboardChartEmpty message={t("noPoolData")} />;
|
||||
}
|
||||
|
||||
const chartHeight = Math.min(420, Math.max(160, rows.length * 32 + 48));
|
||||
const chartHeight = compact
|
||||
? Math.min(220, Math.max(120, rows.length * 22 + 36))
|
||||
: Math.min(420, Math.max(160, rows.length * 32 + 48));
|
||||
|
||||
return (
|
||||
<ChartContainer
|
||||
@@ -445,7 +892,13 @@ export function SoldOutRing({ buckets }: { buckets: SoldOutBuckets }): ReactElem
|
||||
);
|
||||
}
|
||||
|
||||
export function ResultBatchProgress({ draw }: { draw: AdminDashboardDrawPanel }): ReactElement {
|
||||
export function ResultBatchProgress({
|
||||
draw,
|
||||
compact = false,
|
||||
}: {
|
||||
draw: AdminDashboardDrawPanel;
|
||||
compact?: boolean;
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const { total, pending_review, published } = draw.result_batch_counts;
|
||||
const other = Math.max(0, total - pending_review - published);
|
||||
@@ -462,6 +915,43 @@ export function ResultBatchProgress({ draw }: { draw: AdminDashboardDrawPanel })
|
||||
|
||||
const chartData = [{ row: "batches", pending: pending_review, published, other }];
|
||||
|
||||
const statCells = (
|
||||
<div className="grid grid-cols-3 gap-2 text-center">
|
||||
<div className="rounded-lg bg-amber-500/8 px-2 py-2 ring-1 ring-amber-500/15">
|
||||
<p
|
||||
className={cn(
|
||||
"font-bold tabular-nums text-amber-700 dark:text-amber-400",
|
||||
compact ? "text-lg" : "text-2xl",
|
||||
)}
|
||||
>
|
||||
{pending_review}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[10px] text-muted-foreground">{t("batchPending")}</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-emerald-500/8 px-2 py-2 ring-1 ring-emerald-500/15">
|
||||
<p
|
||||
className={cn(
|
||||
"font-bold tabular-nums text-emerald-700 dark:text-emerald-400",
|
||||
compact ? "text-lg" : "text-2xl",
|
||||
)}
|
||||
>
|
||||
{published}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[10px] text-muted-foreground">{t("batchPublished")}</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted/50 px-2 py-2 ring-1 ring-border/60">
|
||||
<p className={cn("font-bold tabular-nums text-foreground", compact ? "text-lg" : "text-2xl")}>
|
||||
{total}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[10px] text-muted-foreground">{t("batchTotal")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (compact) {
|
||||
return statCells;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<ChartContainer config={chartConfig} className="aspect-auto h-10 w-full">
|
||||
@@ -478,20 +968,7 @@ export function ResultBatchProgress({ draw }: { draw: AdminDashboardDrawPanel })
|
||||
<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>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t("batchPending")}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border/60 bg-muted/20 px-2 py-3">
|
||||
<p className="text-2xl font-bold tabular-nums text-emerald-600">{published}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t("batchPublished")}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border/60 bg-muted/20 px-2 py-3">
|
||||
<p className="text-2xl font-bold tabular-nums">{total}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t("batchTotal")}</p>
|
||||
</div>
|
||||
</div>
|
||||
{statCells}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user