refactor: 更新管理端页面元数据,统一国际化支持,移除冗余代码
This commit is contained in:
@@ -24,10 +24,21 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
CapUsageBar,
|
||||
FinanceStructureChart,
|
||||
HotUsageBars,
|
||||
PayoutCompositionChart,
|
||||
ResultBatchProgress,
|
||||
SettlementStatusChart,
|
||||
SoldOutRing,
|
||||
StatCard,
|
||||
} from "@/modules/dashboard/dashboard-visuals";
|
||||
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
||||
import { formatAdminMinorUnits, getAdminCurrencyDecimalPlaces } from "@/lib/money";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type { AdminDashboardDrawPanel } from "@/types/api/admin-dashboard";
|
||||
import type { AdminDrawFinanceSummaryData } from "@/types/api/admin-draw-finance";
|
||||
import type { AdminRiskPoolRow } from "@/types/api/admin-risk";
|
||||
import type { DrawCurrentSnapshot } from "@/types/api/public-draw";
|
||||
@@ -66,7 +77,6 @@ function formatSignedMoneyMinor(minor: number, currencyCode: string | null): str
|
||||
return `${s}${formatMoneyMinor(Math.abs(minor), currencyCode)}`;
|
||||
}
|
||||
|
||||
/** Aligned with the bucket dimensions used by AdminDashboardSnapshotBuilder::soldOutBucketKey. */
|
||||
function poolPlayCategory(normalizedNumber: string): HotPlayTab | "other" {
|
||||
const raw = normalizedNumber.trim();
|
||||
const digits = raw.replace(/\D/g, "");
|
||||
@@ -98,156 +108,6 @@ function topPoolsForTab(pools: AdminRiskPoolRow[], tab: HotPlayTab): AdminRiskPo
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
function RiskSemiGauge({ pct }: { pct: number }): ReactElement {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const v = Math.min(100, Math.max(0, pct));
|
||||
const r = 76;
|
||||
const arcLen = Math.PI * r;
|
||||
|
||||
return (
|
||||
<div className="relative mx-auto flex w-full max-w-[220px] flex-col items-center">
|
||||
<svg viewBox="0 0 200 118" className="w-full" aria-hidden>
|
||||
<path
|
||||
d="M 24 100 A 76 76 0 0 1 176 100"
|
||||
fill="none"
|
||||
stroke="oklch(0.93 0.01 260)"
|
||||
strokeWidth="14"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M 24 100 A 76 76 0 0 1 176 100"
|
||||
fill="none"
|
||||
stroke="oklch(0.55 0.22 25)"
|
||||
strokeWidth="14"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={arcLen}
|
||||
strokeDashoffset={arcLen * (1 - v / 100)}
|
||||
className="transition-[stroke-dashoffset] duration-500 ease-out"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-end pb-1 text-center">
|
||||
<p className="text-lg font-bold tabular-nums text-[#1a365d]">{v.toFixed(2)}%</p>
|
||||
<p className="text-[11px] leading-tight text-muted-foreground">{t("capUsage")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HotBarChart({ rows }: { rows: AdminRiskPoolRow[] }): ReactElement {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const maxU = Math.max(0.0001, ...rows.map((r) => r.usage_ratio ?? 0));
|
||||
|
||||
return (
|
||||
<div className="flex h-52 flex-col">
|
||||
<div className="relative flex h-[168px] min-h-[168px] w-full items-stretch gap-1.5 border-b border-slate-200/90 pb-0.5 pl-7">
|
||||
<span
|
||||
className="pointer-events-none absolute bottom-6 left-0 top-2 w-6 rotate-180 text-[10px] leading-tight text-muted-foreground [writing-mode:vertical-rl]"
|
||||
aria-hidden
|
||||
>
|
||||
{t("capUsage")}
|
||||
</span>
|
||||
{rows.length === 0 ? (
|
||||
<p className="w-full pb-6 text-center text-sm text-muted-foreground">{t("noPoolData")}</p>
|
||||
) : (
|
||||
rows.map((row) => {
|
||||
const u = row.usage_ratio ?? 0;
|
||||
const h = Math.max(8, (u / maxU) * 100);
|
||||
return (
|
||||
<div
|
||||
key={row.normalized_number}
|
||||
className="flex min-h-0 min-w-0 flex-1 flex-col items-stretch gap-1"
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 flex-col justify-end">
|
||||
<div
|
||||
className="mx-auto w-full max-w-[2.25rem] rounded-t-sm bg-[#c41e3a]/90 shadow-sm transition-all"
|
||||
style={{ height: `${h}%`, minHeight: 6 }}
|
||||
title={`${row.normalized_number}: ${(u * 100).toFixed(1)}%`}
|
||||
/>
|
||||
</div>
|
||||
<span className="truncate text-center font-mono text-[10px] text-[#1a365d]">
|
||||
{row.normalized_number.trim()}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1.5 text-center text-[11px] text-muted-foreground">{t("numbersByUsage")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SoldOutDonut({ buckets }: { buckets: SoldOutBuckets }): ReactElement {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const entries: { key: keyof SoldOutBuckets; label: string; color: string }[] = [
|
||||
{ key: "d4", label: t("soldOutBuckets.d4"), color: "oklch(0.32 0.08 260)" },
|
||||
{ key: "d3", label: t("soldOutBuckets.d3"), color: "oklch(0.48 0.12 250)" },
|
||||
{ key: "d2", label: t("soldOutBuckets.d2"), color: "oklch(0.78 0.14 95)" },
|
||||
{ key: "special", label: t("soldOutBuckets.special"), color: "oklch(0.55 0.22 25)" },
|
||||
{ key: "other", label: t("soldOutBuckets.other"), color: "oklch(0.62 0.16 145)" },
|
||||
];
|
||||
const total = entries.reduce((s, e) => s + buckets[e.key], 0);
|
||||
|
||||
if (total === 0) {
|
||||
return (
|
||||
<div className="flex min-h-[200px] flex-col items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<p>{t("noSoldOutNumbers")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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(", ");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-stretch gap-4 sm:flex-row sm:items-center">
|
||||
<div className="relative mx-auto size-44 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 text-center">
|
||||
<p className="text-2xl font-bold tabular-nums text-[#1a365d]">{total}</p>
|
||||
<p className="text-[11px] text-muted-foreground">{t("soldOutTotal")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="min-w-0 flex-1 space-y-2 text-sm">
|
||||
{entries.map((e) => (
|
||||
<li key={e.key} className="flex items-center justify-between gap-3">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="size-2.5 shrink-0 rounded-sm" style={{ background: e.color }} />
|
||||
<span className="text-muted-foreground">{e.label}</span>
|
||||
</span>
|
||||
<span className="font-medium tabular-nums text-[#1a365d]">{buckets[e.key]}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DashboardConsole(): ReactElement {
|
||||
const { t } = useTranslation(["dashboard", "common"]);
|
||||
useAdminCurrencyCatalog();
|
||||
@@ -259,6 +119,7 @@ export function DashboardConsole(): ReactElement {
|
||||
|
||||
const [hall, setHall] = useState<DrawCurrentSnapshot | null>(null);
|
||||
const [drawId, setDrawId] = useState<number | null>(null);
|
||||
const [drawPanel, setDrawPanel] = useState<AdminDashboardDrawPanel | null>(null);
|
||||
const [finance, setFinance] = useState<AdminDrawFinanceSummaryData | null>(null);
|
||||
const [pendingReview, setPendingReview] = useState<number | null>(null);
|
||||
const [riskLocked, setRiskLocked] = useState(0);
|
||||
@@ -277,6 +138,7 @@ export function DashboardConsole(): ReactElement {
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
setFinance(null);
|
||||
setDrawPanel(null);
|
||||
setPendingReview(null);
|
||||
setDrawId(null);
|
||||
setRiskLocked(0);
|
||||
@@ -297,6 +159,7 @@ export function DashboardConsole(): ReactElement {
|
||||
setFinance(d.finance);
|
||||
}
|
||||
if (d.draw != null) {
|
||||
setDrawPanel(d.draw);
|
||||
setPendingReview(d.draw.result_batch_counts.pending_review);
|
||||
}
|
||||
if (d.risk != null) {
|
||||
@@ -326,10 +189,10 @@ export function DashboardConsole(): ReactElement {
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
const t = window.setTimeout(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
void load(false);
|
||||
}, 0);
|
||||
return () => window.clearTimeout(t);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [load]);
|
||||
|
||||
const currency = finance?.currency_code ?? null;
|
||||
@@ -355,19 +218,26 @@ export function DashboardConsole(): ReactElement {
|
||||
{ href: "/admin/audit-logs", label: t("quickLinks.auditLogs"), icon: <ScrollText className="size-5" /> },
|
||||
];
|
||||
|
||||
const kpiSkeleton = (
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="rounded-xl border border-border/80 bg-card p-5 shadow-sm">
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-10">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-[#1a365d]">{t("title")}</h1>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">{t("title")}</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">{todayLabel}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-slate-300"
|
||||
disabled={loading || refreshing}
|
||||
onClick={() => void load(true)}
|
||||
>
|
||||
@@ -391,181 +261,131 @@ export function DashboardConsole(): ReactElement {
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{/* Row 1 - Core finance KPI */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{loading ? (
|
||||
Array.from({ length: 3 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-xl border border-slate-200/90 bg-white p-5 shadow-sm"
|
||||
>
|
||||
{loading ? (
|
||||
kpiSkeleton
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<StatCard
|
||||
label={t("todayBetTotal")}
|
||||
value={finance ? formatMoneyMinor(finance.total_bet_minor, currency) : "—"}
|
||||
hint={hall?.draw_no ? t("drawNoHint", { drawNo: hall.draw_no }) : undefined}
|
||||
icon={<Wallet className="size-5" aria-hidden />}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("currentPayout")}
|
||||
value={finance ? formatMoneyMinor(finance.total_payout_minor, currency) : "—"}
|
||||
hint={
|
||||
finance
|
||||
? t("orderAndTicket", {
|
||||
orders: finance.order_count.toLocaleString("zh-CN"),
|
||||
tickets: finance.ticket_item_count.toLocaleString("zh-CN"),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
icon={<Gift className="size-5" aria-hidden />}
|
||||
accent="destructive"
|
||||
/>
|
||||
<StatCard
|
||||
label={t("currentProfit")}
|
||||
value={finance ? formatSignedMoneyMinor(finance.approx_house_gross_minor, currency) : "—"}
|
||||
hint={finance && finance.total_bet_minor > 0
|
||||
? t("marginRate", {
|
||||
rate: ((finance.approx_house_gross_minor / finance.total_bet_minor) * 100).toFixed(1),
|
||||
})
|
||||
: undefined}
|
||||
icon={<TrendingUp className="size-5" aria-hidden />}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("currentDraw")}
|
||||
value={<span className="font-mono text-primary">{hall?.draw_no ?? "—"}</span>}
|
||||
hint={
|
||||
<span className="inline-flex flex-wrap items-center gap-2">
|
||||
<span>{t("drawSequence", { sequence: hall?.sequence_no ?? "—" })}</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 rounded-full",
|
||||
isOpenLike ? "bg-emerald-500" : "bg-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
{hallStatusLabel}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
icon={<Ticket className="size-5" aria-hidden />}
|
||||
accent="muted"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2 xl:grid-cols-3">
|
||||
<Card className="border-border/80 shadow-sm xl:col-span-1">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">{t("financeStructure")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-36 w-full" />
|
||||
) : finance ? (
|
||||
<FinanceStructureChart finance={finance} formatMoney={formatMoneyMinor} />
|
||||
) : (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">{t("states.noData", { ns: "common" })}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/80 shadow-sm xl:col-span-1">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">{t("payoutComposition")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-36 w-full" />
|
||||
) : finance ? (
|
||||
<PayoutCompositionChart finance={finance} formatMoney={formatMoneyMinor} />
|
||||
) : (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">{t("states.noData", { ns: "common" })}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/80 shadow-sm lg:col-span-2 xl:col-span-1">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-base">{t("riskCapUsage")}</CardTitle>
|
||||
{drawId != null ? (
|
||||
<Link
|
||||
href={`/admin/draws/${drawId}/risk/occupancy`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"h-7 px-2 text-xs",
|
||||
)}
|
||||
>
|
||||
{t("occupancyDetails")}
|
||||
</Link>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-xl border border-slate-200/90 bg-white p-5 shadow-sm">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex size-12 shrink-0 items-center justify-center rounded-full bg-[#c41e3a] text-white shadow-md">
|
||||
<Wallet className="size-5" aria-hidden />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-slate-600">{t("todayBetTotal")}</p>
|
||||
<p className="mt-1 text-2xl font-bold tabular-nums text-[#1a365d]">
|
||||
{finance ? formatMoneyMinor(finance.total_bet_minor, currency) : "—"}
|
||||
</p>
|
||||
<p className="mt-2 flex items-center gap-1 text-xs text-emerald-600">
|
||||
<TrendingUp className="size-3.5 shrink-0" aria-hidden />
|
||||
{t("currentDrawFinanceSummary")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-200/90 bg-white p-5 shadow-sm">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex size-12 shrink-0 items-center justify-center rounded-full bg-[#2563eb] text-white shadow-md">
|
||||
<Gift className="size-5" aria-hidden />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-slate-600">{t("currentPayout")}</p>
|
||||
<p className="mt-1 text-2xl font-bold tabular-nums text-[#1a365d]">
|
||||
{finance ? formatMoneyMinor(finance.total_payout_minor, currency) : "—"}
|
||||
</p>
|
||||
<p className="mt-2 flex items-center gap-1 text-xs text-emerald-600">
|
||||
<TrendingUp className="size-3.5 shrink-0" aria-hidden />
|
||||
{t("payoutSummary")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-200/90 bg-white p-5 shadow-sm">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex size-12 shrink-0 items-center justify-center rounded-full bg-[#1a365d] text-white shadow-md">
|
||||
<TrendingUp className="size-5" aria-hidden />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-slate-600">{t("currentProfit")}</p>
|
||||
<p className="mt-1 text-2xl font-bold tabular-nums text-[#1a365d]">
|
||||
{finance ? formatSignedMoneyMinor(finance.approx_house_gross_minor, currency) : "—"}
|
||||
</p>
|
||||
<p className="mt-2 flex items-center gap-1 text-xs text-emerald-600">
|
||||
<TrendingUp className="size-3.5 shrink-0" aria-hidden />
|
||||
{t("profitFormula")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
) : (
|
||||
<CapUsageBar
|
||||
locked={riskLocked}
|
||||
cap={riskCap}
|
||||
usagePct={usagePct}
|
||||
formatMoney={formatMoneyMinor}
|
||||
currency={currency}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Row 2 - Draw / betting / risk */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{loading ? (
|
||||
Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="rounded-xl border border-slate-200/90 bg-white p-5 shadow-sm">
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-xl border border-slate-200/90 bg-white p-5 shadow-sm">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex size-12 shrink-0 items-center justify-center rounded-lg bg-[#1a365d] text-white shadow-md">
|
||||
<Ticket className="size-5" aria-hidden />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-slate-600">{t("currentDraw")}</p>
|
||||
<p className="mt-1 font-mono text-2xl font-bold text-[#c41e3a]">{hall?.draw_no ?? "—"}</p>
|
||||
<p className="mt-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{t("drawSequence", { sequence: hall?.sequence_no ?? "—" })}</span>
|
||||
<span className="hidden sm:inline">·</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 rounded-full",
|
||||
isOpenLike ? "bg-emerald-500" : "bg-slate-400",
|
||||
)}
|
||||
/>
|
||||
{hallStatusLabel}
|
||||
</span>
|
||||
</p>
|
||||
{drawId != null ? (
|
||||
<Link
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"mt-2 h-7 border-[#2563eb]/30 px-2 text-xs text-[#2563eb] hover:bg-[#2563eb]/5",
|
||||
)}
|
||||
href={`/admin/draws/${drawId}`}
|
||||
>
|
||||
{t("drawDetails")}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-200/90 bg-white p-5 shadow-sm">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex size-12 shrink-0 items-center justify-center rounded-lg bg-[#1a365d] text-white shadow-md">
|
||||
<Wallet className="size-5" aria-hidden />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-slate-600">{t("ticketCount")}</p>
|
||||
<p className="mt-1 text-2xl font-bold tabular-nums text-[#1a365d]">
|
||||
{finance != null ? finance.ticket_item_count.toLocaleString("zh-CN") : "—"}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{t("relatedBetAmount")}{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{finance ? formatMoneyMinor(finance.total_bet_minor, currency) : "—"}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-200/90 bg-white p-5 shadow-sm">
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-start sm:gap-4">
|
||||
<div className="flex size-12 shrink-0 items-center justify-center rounded-lg bg-[#1a365d] text-white shadow-md">
|
||||
<Shield className="size-5" aria-hidden />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-center sm:text-left">
|
||||
<p className="text-sm font-medium text-slate-600">{t("riskCapUsage")}</p>
|
||||
<p className="mt-1 text-xs tabular-nums text-muted-foreground">
|
||||
{t("lockedAndCap", {
|
||||
locked: formatMoneyMinor(riskLocked, currency),
|
||||
cap: formatMoneyMinor(riskCap, currency),
|
||||
})}
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<RiskSemiGauge pct={usagePct} />
|
||||
</div>
|
||||
{drawId != null ? (
|
||||
<Link
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"mt-1 h-7 border-[#2563eb]/30 px-2 text-xs text-[#2563eb] hover:bg-[#2563eb]/5",
|
||||
)}
|
||||
href={`/admin/draws/${drawId}/risk/occupancy`}
|
||||
>
|
||||
{t("occupancyDetails")}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Row 3 - Charts */}
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Card className="border-slate-200/90 shadow-sm">
|
||||
<Card className="border-border/80 shadow-sm">
|
||||
<CardHeader className="flex flex-row flex-wrap items-end justify-between gap-2 space-y-0 pb-2">
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold text-[#1a365d]">{t("hotNumbersTop10")}</CardTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div role="tablist" aria-label={t("playDimension")} className="flex gap-1 border-b border-transparent">
|
||||
<CardTitle className="text-base">{t("hotNumbersTop10")}</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<div role="tablist" aria-label={t("playDimension")} className="flex gap-1">
|
||||
{([
|
||||
{ value: "4D", label: t("tabs.4d") },
|
||||
{ value: "3D", label: t("tabs.3d") },
|
||||
@@ -578,10 +398,10 @@ export function DashboardConsole(): ReactElement {
|
||||
role="tab"
|
||||
aria-selected={hotTab === tab.value}
|
||||
className={cn(
|
||||
"-mb-px border-b-2 px-2.5 py-1 text-sm font-medium transition-colors",
|
||||
"rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
hotTab === tab.value
|
||||
? "border-[#c41e3a] text-[#c41e3a]"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground",
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-muted",
|
||||
)}
|
||||
onClick={() => setHotTab(tab.value)}
|
||||
>
|
||||
@@ -592,10 +412,7 @@ export function DashboardConsole(): ReactElement {
|
||||
{drawId != null ? (
|
||||
<Link
|
||||
href={`/admin/draws/${drawId}/risk/hot`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"h-7 border-[#2563eb]/30 px-2 text-xs text-[#2563eb] hover:bg-[#2563eb]/5",
|
||||
)}
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 px-2 text-xs")}
|
||||
>
|
||||
{t("actions.viewAll", { ns: "common" })}
|
||||
</Link>
|
||||
@@ -603,26 +420,17 @@ export function DashboardConsole(): ReactElement {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-52 w-full" />
|
||||
) : (
|
||||
<HotBarChart rows={hotRows} />
|
||||
)}
|
||||
{loading ? <Skeleton className="h-64 w-full" /> : <HotUsageBars rows={hotRows} />}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-slate-200/90 shadow-sm">
|
||||
<Card className="border-border/80 shadow-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold text-[#1a365d]">{t("soldOutDistribution")}</CardTitle>
|
||||
</div>
|
||||
<CardTitle className="text-base">{t("soldOutDistribution")}</CardTitle>
|
||||
{drawId != null ? (
|
||||
<Link
|
||||
href={`/admin/draws/${drawId}/risk/sold-out`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"h-7 border-[#2563eb]/30 px-2 text-xs text-[#2563eb] hover:bg-[#2563eb]/5",
|
||||
)}
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 px-2 text-xs")}
|
||||
>
|
||||
{t("actions.viewAll", { ns: "common" })}
|
||||
</Link>
|
||||
@@ -630,76 +438,115 @@ export function DashboardConsole(): ReactElement {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-52 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
) : soldOutBuckets ? (
|
||||
<SoldOutDonut buckets={soldOutBuckets} />
|
||||
<SoldOutRing buckets={soldOutBuckets} />
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{t("states.noData", { ns: "common" })}</p>
|
||||
<p className="py-10 text-center text-sm text-muted-foreground">{t("states.noData", { ns: "common" })}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Row 4 - To-do */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="flex flex-col justify-between gap-4 rounded-xl border border-slate-200/90 bg-white p-5 shadow-sm sm:flex-row sm:items-center">
|
||||
<Card className="border-border/80 shadow-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-base">{t("resultBatches")}</CardTitle>
|
||||
{drawId != null ? (
|
||||
<Link
|
||||
href={`/admin/draws/${drawId}`}
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 px-2 text-xs")}
|
||||
>
|
||||
{t("drawDetails")}
|
||||
</Link>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-28 w-full" />
|
||||
) : drawPanel ? (
|
||||
<ResultBatchProgress draw={drawPanel} />
|
||||
) : (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">{t("states.noData", { ns: "common" })}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/80 shadow-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-base">{t("settlementOverview")}</CardTitle>
|
||||
{drawId != null ? (
|
||||
<Link
|
||||
href="/admin/settlement-batches"
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 px-2 text-xs")}
|
||||
>
|
||||
{t("actions.viewAll", { ns: "common" })}
|
||||
</Link>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-28 w-full" />
|
||||
) : finance ? (
|
||||
<SettlementStatusChart finance={finance} />
|
||||
) : (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">{t("states.noData", { ns: "common" })}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="flex flex-col justify-between gap-4 rounded-xl border border-border/80 bg-card p-5 shadow-sm sm:flex-row sm:items-center">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex size-12 shrink-0 items-center justify-center rounded-full bg-[#c41e3a] text-white shadow-md">
|
||||
<div className="flex size-12 shrink-0 items-center justify-center rounded-full bg-destructive text-destructive-foreground shadow-sm">
|
||||
<ClipboardList className="size-5" aria-hidden />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-600">{t("pendingReviewResults")}</p>
|
||||
<p className="mt-1 text-4xl font-bold tabular-nums text-[#c41e3a]">
|
||||
{pendingReview ?? "—"}
|
||||
</p>
|
||||
<p className="text-sm font-medium text-muted-foreground">{t("pendingReviewResults")}</p>
|
||||
<p className="mt-1 text-4xl font-bold tabular-nums text-destructive">{pendingReview ?? "—"}</p>
|
||||
</div>
|
||||
</div>
|
||||
{drawId != null ? (
|
||||
<Link
|
||||
href={`/admin/draws/${drawId}/review`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "default" }),
|
||||
"shrink-0 border-[#c41e3a] text-[#c41e3a] hover:bg-[#c41e3a]/5",
|
||||
)}
|
||||
className={cn(buttonVariants({ variant: "outline" }), "shrink-0")}
|
||||
>
|
||||
{t("actions.reviewNow", { ns: "common" })}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-col justify-between gap-4 rounded-xl border border-slate-200/90 bg-white p-5 shadow-sm sm:flex-row sm:items-center">
|
||||
<div className="flex flex-col justify-between gap-4 rounded-xl border border-border/80 bg-card p-5 shadow-sm sm:flex-row sm:items-center">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex size-12 shrink-0 items-center justify-center rounded-full bg-[#c41e3a] text-white shadow-md">
|
||||
<div className="flex size-12 shrink-0 items-center justify-center rounded-full bg-amber-500 text-white shadow-sm">
|
||||
<AlertTriangle className="size-5" aria-hidden />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-600">{t("abnormalTransferOrders")}</p>
|
||||
<p className="mt-1 text-4xl font-bold tabular-nums text-[#c41e3a]">
|
||||
{abnormalTransferTotal ?? "—"}
|
||||
</p>
|
||||
<p className="text-sm font-medium text-muted-foreground">{t("abnormalTransferOrders")}</p>
|
||||
<p className="mt-1 text-4xl font-bold tabular-nums text-amber-600">{abnormalTransferTotal ?? "—"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin/wallet/transfer-orders"
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "default" }),
|
||||
"shrink-0 border-[#c41e3a] text-[#c41e3a] hover:bg-[#c41e3a]/5",
|
||||
)}
|
||||
className={cn(buttonVariants({ variant: "outline" }), "shrink-0")}
|
||||
>
|
||||
{t("viewTransferOrders")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 5 - Quick links */}
|
||||
<Card className="border-slate-200/90 shadow-sm">
|
||||
<CardContent className="flex flex-wrap justify-center gap-3 py-6 sm:gap-6">
|
||||
<Card className="border-border/80 shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">{t("quickLinksTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap justify-center gap-3 py-2 sm:gap-5">
|
||||
{quickLinks.map((q) => (
|
||||
<Link
|
||||
key={q.label}
|
||||
href={q.href}
|
||||
className="flex w-[5.5rem] flex-col items-center gap-2 rounded-lg border border-transparent p-2 text-center text-xs font-medium text-[#1a365d] transition-colors hover:border-slate-200 hover:bg-slate-50 sm:w-24"
|
||||
className="flex w-24 flex-col items-center gap-2 rounded-lg border border-transparent p-2 text-center text-xs font-medium text-foreground transition-colors hover:border-border hover:bg-muted/50"
|
||||
>
|
||||
<span className="flex size-11 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-800 shadow-sm">
|
||||
<span className="flex size-11 items-center justify-center rounded-full border border-border bg-card text-foreground shadow-sm">
|
||||
{q.icon}
|
||||
</span>
|
||||
{q.label}
|
||||
@@ -707,7 +554,6 @@ export function DashboardConsole(): ReactElement {
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
402
src/modules/dashboard/dashboard-visuals.tsx
Normal file
402
src/modules/dashboard/dashboard-visuals.tsx
Normal file
@@ -0,0 +1,402 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AdminDrawFinanceSummaryData } from "@/types/api/admin-draw-finance";
|
||||
import type { AdminRiskPoolRow } from "@/types/api/admin-risk";
|
||||
import type {
|
||||
AdminDashboardDrawPanel,
|
||||
AdminDashboardSoldOutBuckets,
|
||||
} from "@/types/api/admin-dashboard";
|
||||
|
||||
export type SoldOutBuckets = AdminDashboardSoldOutBuckets;
|
||||
|
||||
type MoneyFormatter = (minor: number, currency: string | null) => string;
|
||||
|
||||
export function StatCard({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
icon,
|
||||
accent = "primary",
|
||||
}: {
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
hint?: ReactNode;
|
||||
icon: ReactNode;
|
||||
accent?: "primary" | "destructive" | "muted";
|
||||
}): ReactElement {
|
||||
const accentClass =
|
||||
accent === "destructive"
|
||||
? "bg-destructive text-destructive-foreground"
|
||||
: accent === "muted"
|
||||
? "bg-muted text-foreground"
|
||||
: "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">
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-11 shrink-0 items-center justify-center rounded-lg shadow-sm",
|
||||
accentClass,
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<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}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CapUsageBar({
|
||||
locked,
|
||||
cap,
|
||||
usagePct,
|
||||
formatMoney,
|
||||
currency,
|
||||
}: {
|
||||
locked: number;
|
||||
cap: number;
|
||||
usagePct: number;
|
||||
formatMoney: MoneyFormatter;
|
||||
currency: string | null;
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const pct = Math.min(100, Math.max(0, usagePct));
|
||||
|
||||
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">
|
||||
{t("lockedAndCap", {
|
||||
locked: formatMoney(locked, currency),
|
||||
cap: formatMoney(cap, currency),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FinanceStructureChart({
|
||||
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 payout = finance.total_payout_minor;
|
||||
const gross = finance.approx_house_gross_minor;
|
||||
|
||||
if (bet <= 0) {
|
||||
return <p className="py-8 text-center text-sm text-muted-foreground">{t("noFinanceActivity")}</p>;
|
||||
}
|
||||
|
||||
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-chart-2", label: t("winPayout"), value: win },
|
||||
{ key: "jackpot", width: jpW, className: "bg-chart-4", label: t("jackpotPayout"), value: jackpot },
|
||||
{ key: "gross", width: grossW, className: "bg-primary", label: t("houseGross"), value: gross },
|
||||
].filter((s) => s.width > 0.05);
|
||||
|
||||
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)}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PayoutCompositionChart({
|
||||
finance,
|
||||
formatMoney,
|
||||
}: {
|
||||
finance: AdminDrawFinanceSummaryData;
|
||||
formatMoney: MoneyFormatter;
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const currency = finance.currency_code;
|
||||
const win = finance.total_win_payout_minor;
|
||||
const jackpot = finance.total_jackpot_win_minor;
|
||||
const total = win + jackpot;
|
||||
|
||||
if (total <= 0) {
|
||||
return <p className="py-8 text-center text-sm text-muted-foreground">{t("noPayoutYet")}</p>;
|
||||
}
|
||||
|
||||
const winPct = (win / total) * 100;
|
||||
const items = [
|
||||
{ label: t("winPayout"), value: win, pct: winPct, className: "bg-chart-2" },
|
||||
{ label: t("jackpotPayout"), value: jackpot, pct: 100 - winPct, className: "bg-chart-4" },
|
||||
];
|
||||
|
||||
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, var(--chart-2) 0deg ${winPct * 3.6}deg, var(--chart-4) ${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={cn("h-full rounded-full", item.className)} style={{ width: `${item.pct}%` }} />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HotUsageBars({ rows }: { rows: AdminRiskPoolRow[] }): ReactElement {
|
||||
const { t } = useTranslation("dashboard");
|
||||
|
||||
if (rows.length === 0) {
|
||||
return <p className="py-10 text-center text-sm text-muted-foreground">{t("noPoolData")}</p>;
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export function SoldOutRing({ buckets }: { buckets: SoldOutBuckets }): ReactElement {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const entries: { key: keyof SoldOutBuckets; label: string; color: string }[] = [
|
||||
{ key: "d4", label: t("soldOutBuckets.d4"), color: "var(--chart-1)" },
|
||||
{ key: "d3", label: t("soldOutBuckets.d3"), color: "var(--chart-2)" },
|
||||
{ key: "d2", label: t("soldOutBuckets.d2"), color: "var(--chart-3)" },
|
||||
{ key: "special", label: t("soldOutBuckets.special"), color: "var(--chart-4)" },
|
||||
{ key: "other", label: t("soldOutBuckets.other"), color: "var(--chart-5)" },
|
||||
];
|
||||
const total = entries.reduce((s, e) => s + buckets[e.key], 0);
|
||||
|
||||
if (total === 0) {
|
||||
return <p className="py-10 text-center text-sm text-muted-foreground">{t("noSoldOutNumbers")}</p>;
|
||||
}
|
||||
|
||||
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(", ");
|
||||
|
||||
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="size-2.5 rounded-sm" style={{ background: e.color }} />
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettlementStatusChart({
|
||||
finance,
|
||||
}: {
|
||||
finance: AdminDrawFinanceSummaryData;
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const batches = 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]));
|
||||
|
||||
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="h-full rounded-full bg-primary/80"
|
||||
style={{ width: `${max > 0 ? (count / max) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user