refactor: 合并多语言支持的显示名称字段,优化奖池手动爆发功能的返回数据结构,增强管理端权限控制
This commit is contained in:
386
src/modules/dashboard/dashboard-analytics-panel.tsx
Normal file
386
src/modules/dashboard/dashboard-analytics-panel.tsx
Normal file
@@ -0,0 +1,386 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react";
|
||||
import { format, subDays } from "date-fns";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BarChart3, Gift, TrendingUp, Wallet } from "lucide-react";
|
||||
|
||||
import { getAdminDashboardAnalytics } from "@/api/admin-dashboard";
|
||||
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog";
|
||||
import { formatAdminMinorUnits, getAdminCurrencyDecimalPlaces } from "@/lib/money";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { StatCard } from "@/modules/dashboard/dashboard-visuals";
|
||||
import {
|
||||
DailyTrendChart,
|
||||
PeriodCompareStrip,
|
||||
PlayBreakdownChart,
|
||||
} from "@/modules/dashboard/dashboard-trend-charts";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type {
|
||||
AdminDashboardAnalyticsData,
|
||||
DashboardAnalyticsMetric,
|
||||
DashboardAnalyticsPeriod,
|
||||
} from "@/types/api/admin-dashboard-analytics";
|
||||
|
||||
const PERIOD_OPTIONS: DashboardAnalyticsPeriod[] = [
|
||||
"today",
|
||||
"last_7_days",
|
||||
"last_30_days",
|
||||
"this_month",
|
||||
"lifetime",
|
||||
"custom",
|
||||
];
|
||||
|
||||
const METRIC_OPTIONS: DashboardAnalyticsMetric[] = ["overview", "bet", "payout", "profit"];
|
||||
|
||||
function formatMoneyMinor(minor: number, currencyCode: string | null): string {
|
||||
const code = (currencyCode ?? "NPR").toUpperCase();
|
||||
const decimals = getAdminCurrencyDecimalPlaces(code);
|
||||
const major = minor / 10 ** decimals;
|
||||
try {
|
||||
return new Intl.NumberFormat("zh-CN", {
|
||||
style: "currency",
|
||||
currency: code,
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals,
|
||||
}).format(major);
|
||||
} catch {
|
||||
return formatAdminMinorUnits(minor, code, decimals);
|
||||
}
|
||||
}
|
||||
|
||||
function formatSignedMoneyMinor(minor: number, currencyCode: string | null): string {
|
||||
if (minor === 0) {
|
||||
return formatMoneyMinor(0, currencyCode);
|
||||
}
|
||||
const s = minor > 0 ? "+" : "−";
|
||||
return `${s}${formatMoneyMinor(Math.abs(minor), currencyCode)}`;
|
||||
}
|
||||
|
||||
export function DashboardAnalyticsPanel({
|
||||
enabled,
|
||||
playOptions,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
playOptions: { code: string; label: string }[];
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation(["dashboard", "common"]);
|
||||
const playLabel = useAdminPlayCodeLabel();
|
||||
|
||||
const [period, setPeriod] = useState<DashboardAnalyticsPeriod>("last_7_days");
|
||||
const [metric, setMetric] = useState<DashboardAnalyticsMetric>("overview");
|
||||
const [playCode, setPlayCode] = useState<string>("");
|
||||
const [customFrom, setCustomFrom] = useState(() => format(subDays(new Date(), 6), "yyyy-MM-dd"));
|
||||
const [customTo, setCustomTo] = useState(() => format(new Date(), "yyyy-MM-dd"));
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [data, setData] = useState<AdminDashboardAnalyticsData | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!enabled) {
|
||||
setLoading(false);
|
||||
setData(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const payload = await getAdminDashboardAnalytics({
|
||||
period,
|
||||
metric,
|
||||
play_code: playCode !== "" ? playCode : undefined,
|
||||
...(period === "custom"
|
||||
? { date_from: customFrom, date_to: customTo }
|
||||
: {}),
|
||||
});
|
||||
setData(payload);
|
||||
} catch (e) {
|
||||
setData(null);
|
||||
const raw = e instanceof LotteryApiBizError ? e.message : "";
|
||||
const needsAuthSync =
|
||||
raw.includes("admin.dashboard.analytics") || raw.includes("资源未配置");
|
||||
setError(
|
||||
needsAuthSync ? t("warnings.apiResourceMissing") : raw || t("warnings.loadFailed"),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [enabled, period, metric, playCode, customFrom, customTo, t]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
void load();
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [load]);
|
||||
|
||||
const currency = data?.currency_code ?? null;
|
||||
const summary = data?.summary;
|
||||
|
||||
const periodRangeLabel = useMemo(() => {
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
return data.date_from === data.date_to
|
||||
? data.date_from
|
||||
: `${data.date_from} — ${data.date_to}`;
|
||||
}, [data]);
|
||||
|
||||
const metricLabel = useMemo(
|
||||
() => t(`analytics.metrics.${metric}`),
|
||||
[metric, t],
|
||||
);
|
||||
|
||||
const playFilterLabel = useMemo(() => {
|
||||
if (playCode === "") {
|
||||
return t("analytics.allPlays");
|
||||
}
|
||||
return playOptions.find((p) => p.code === playCode)?.label ?? playCode;
|
||||
}, [playCode, playOptions, t]);
|
||||
|
||||
const resolvePlayLabel = useCallback(
|
||||
(code: string, dimension: number) => {
|
||||
const base = playLabel(code);
|
||||
return dimension > 0 ? `${base} · ${dimension}D` : base;
|
||||
},
|
||||
[playLabel],
|
||||
);
|
||||
|
||||
if (!enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<Card className="border-border/80 shadow-sm">
|
||||
<CardHeader className="space-y-4 pb-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<CardTitle className="text-base">{t("analytics.title")}</CardTitle>
|
||||
<Link
|
||||
href="/admin/reports"
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "h-8 gap-1.5 text-xs")}
|
||||
>
|
||||
<BarChart3 className="size-3.5" aria-hidden />
|
||||
{t("viewReports")}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5" role="group" aria-label={t("analytics.periodLabel")}>
|
||||
{PERIOD_OPTIONS.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
className={cn(
|
||||
"rounded-md border px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
period === p
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-border bg-card text-muted-foreground hover:bg-muted",
|
||||
)}
|
||||
onClick={() => setPeriod(p)}
|
||||
>
|
||||
{t(`analytics.periods.${p}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[1fr_auto_auto] lg:items-end">
|
||||
{period === "custom" ? (
|
||||
<AdminDateRangeField
|
||||
id="dashboard-analytics-range"
|
||||
label={t("analytics.customRange")}
|
||||
from={customFrom}
|
||||
to={customTo}
|
||||
onRangeChange={({ from, to }) => {
|
||||
setCustomFrom(from);
|
||||
setCustomTo(to);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground lg:col-span-1">
|
||||
{periodRangeLabel
|
||||
? t("analytics.rangeHint", { range: periodRangeLabel })
|
||||
: t("analytics.selectPeriod")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">{t("analytics.metricLabel")}</Label>
|
||||
<Select value={metric} onValueChange={(v) => setMetric(v as DashboardAnalyticsMetric)}>
|
||||
<SelectTrigger className="w-full min-w-[140px]">
|
||||
<SelectValue>{metricLabel}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{METRIC_OPTIONS.map((m) => (
|
||||
<SelectItem key={m} value={m}>
|
||||
{t(`analytics.metrics.${m}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">{t("analytics.playLabel")}</Label>
|
||||
<Select
|
||||
value={playCode === "" ? "__all__" : playCode}
|
||||
onValueChange={(v) => setPlayCode(v === "__all__" ? "" : v)}
|
||||
>
|
||||
<SelectTrigger className="w-full min-w-[160px]">
|
||||
<SelectValue>{playFilterLabel}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">{t("analytics.allPlays")}</SelectItem>
|
||||
{playOptions.map((p) => (
|
||||
<SelectItem key={p.code} value={p.code}>
|
||||
{p.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{error ? (
|
||||
<Alert className="border-amber-200 bg-amber-50 dark:border-amber-900/60 dark:bg-amber-950/30">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{data?.chart_meta.truncated ? (
|
||||
<p className="text-xs text-amber-700 dark:text-amber-400">
|
||||
{t("analytics.chartTruncated", {
|
||||
from: data.chart_meta.chart_date_from,
|
||||
to: data.chart_meta.chart_date_to,
|
||||
days: data.chart_meta.span_days,
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-24 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : summary ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<StatCard
|
||||
label={t("analytics.summaryBet")}
|
||||
value={formatMoneyMinor(summary.total_bet_minor, currency)}
|
||||
hint={t("lifetimeActivityHint", {
|
||||
draws: summary.draw_count.toLocaleString("zh-CN"),
|
||||
days: summary.business_day_count.toLocaleString("zh-CN"),
|
||||
})}
|
||||
icon={<Wallet className="size-5" aria-hidden />}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("analytics.summaryPayout")}
|
||||
value={formatMoneyMinor(summary.total_payout_minor, currency)}
|
||||
hint={
|
||||
summary.total_bet_minor > 0
|
||||
? t("payoutRateOfBet", {
|
||||
rate: ((summary.total_payout_minor / summary.total_bet_minor) * 100).toFixed(1),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
icon={<Gift className="size-5" aria-hidden />}
|
||||
accent="destructive"
|
||||
/>
|
||||
<StatCard
|
||||
label={t("analytics.summaryProfit")}
|
||||
value={formatSignedMoneyMinor(summary.approx_house_gross_minor, currency)}
|
||||
hint={
|
||||
summary.total_bet_minor > 0
|
||||
? t("marginRate", {
|
||||
rate: ((summary.approx_house_gross_minor / summary.total_bet_minor) * 100).toFixed(1),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
icon={<TrendingUp className="size-5" aria-hidden />}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2 lg:items-start">
|
||||
<Card className="flex h-full flex-col border-border/80 shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">{t("analytics.dailyTrend")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pb-4">
|
||||
{loading ? (
|
||||
<Skeleton className="h-[220px] w-full" />
|
||||
) : data ? (
|
||||
<DailyTrendChart
|
||||
series={data.daily_series}
|
||||
metric={metric}
|
||||
formatMoney={formatMoneyMinor}
|
||||
currency={currency}
|
||||
/>
|
||||
) : (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">{t("states.noData", { ns: "common" })}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="flex h-full flex-col border-border/80 shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">{t("analytics.playBreakdown")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pb-4">
|
||||
{loading ? (
|
||||
<Skeleton className="h-[220px] w-full" />
|
||||
) : data ? (
|
||||
<div className="max-h-[280px] overflow-y-auto pr-1">
|
||||
<PlayBreakdownChart
|
||||
rows={data.play_breakdown}
|
||||
metric={metric}
|
||||
formatMoney={formatMoneyMinor}
|
||||
currency={currency}
|
||||
playLabel={resolvePlayLabel}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">{t("states.noData", { ns: "common" })}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{data && !loading ? (
|
||||
<Card className="border-border/80 shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">{t("analytics.periodDistribution")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PeriodCompareStrip
|
||||
series={data.daily_series}
|
||||
formatMoney={formatMoneyMinor}
|
||||
currency={currency}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -2,24 +2,28 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactElement, type ReactNode } from "react";
|
||||
import { format } from "date-fns";
|
||||
import { zhCN } from "date-fns/locale";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ClipboardList,
|
||||
Diamond,
|
||||
FileSearch,
|
||||
Gift,
|
||||
RefreshCw,
|
||||
ScrollText,
|
||||
Shield,
|
||||
Ticket,
|
||||
TrendingUp,
|
||||
Wallet,
|
||||
} from "lucide-react";
|
||||
|
||||
import { getAdminDashboard } from "@/api/admin-dashboard";
|
||||
import { useAdminPlayTypeCatalog } from "@/hooks/use-admin-play-type-catalog";
|
||||
import { getAdminPlayTypes } from "@/api/admin-config";
|
||||
import {
|
||||
getAdminPlayTypesLoadPromise,
|
||||
getCachedAdminPlayTypes,
|
||||
resolveAdminPlayTypeDisplayName,
|
||||
} from "@/lib/admin-play-types";
|
||||
import { DashboardAnalyticsPanel } from "@/modules/dashboard/dashboard-analytics-panel";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -32,9 +36,10 @@ import {
|
||||
ResultBatchProgress,
|
||||
SettlementStatusChart,
|
||||
SoldOutRing,
|
||||
StatCard,
|
||||
} from "@/modules/dashboard/dashboard-visuals";
|
||||
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
||||
import { adminWeekdayKeyForDate, formatAdminCalendarToday } from "@/lib/admin-datetime";
|
||||
import { normalizeAdminLanguage } from "@/i18n";
|
||||
import { formatAdminMinorUnits, getAdminCurrencyDecimalPlaces } from "@/lib/money";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
@@ -69,14 +74,6 @@ function formatMoneyMinor(minor: number, currencyCode: string | null): string {
|
||||
}
|
||||
}
|
||||
|
||||
function formatSignedMoneyMinor(minor: number, currencyCode: string | null): string {
|
||||
if (minor === 0) {
|
||||
return formatMoneyMinor(0, currencyCode);
|
||||
}
|
||||
const s = minor > 0 ? "+" : "−";
|
||||
return `${s}${formatMoneyMinor(Math.abs(minor), currencyCode)}`;
|
||||
}
|
||||
|
||||
function poolPlayCategory(normalizedNumber: string): HotPlayTab | "other" {
|
||||
const raw = normalizedNumber.trim();
|
||||
const digits = raw.replace(/\D/g, "");
|
||||
@@ -109,18 +106,24 @@ function topPoolsForTab(pools: AdminRiskPoolRow[], tab: HotPlayTab): AdminRiskPo
|
||||
}
|
||||
|
||||
export function DashboardConsole(): ReactElement {
|
||||
const { t } = useTranslation(["dashboard", "common"]);
|
||||
const { t, i18n } = useTranslation(["dashboard", "common"]);
|
||||
useAdminCurrencyCatalog();
|
||||
const [todayLabel] = useState(() => format(new Date(), "yyyy-MM-dd EEEE", { locale: zhCN }));
|
||||
useAdminPlayTypeCatalog();
|
||||
const todayLabel = useMemo(() => {
|
||||
const locale = normalizeAdminLanguage(i18n.resolvedLanguage ?? i18n.language);
|
||||
const weekday = t(`date.weekdays.${adminWeekdayKeyForDate()}`, { ns: "common" });
|
||||
|
||||
return formatAdminCalendarToday(locale, weekday);
|
||||
}, [i18n.language, i18n.resolvedLanguage, t]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
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 [capabilities, setCapabilities] = useState<{ draw_finance_risk: boolean; wallet_transfer_view: boolean } | null>(null);
|
||||
const [pendingReview, setPendingReview] = useState<number | null>(null);
|
||||
const [riskLocked, setRiskLocked] = useState(0);
|
||||
const [riskCap, setRiskCap] = useState(0);
|
||||
@@ -128,6 +131,26 @@ export function DashboardConsole(): ReactElement {
|
||||
const [soldOutBuckets, setSoldOutBuckets] = useState<SoldOutBuckets | null>(null);
|
||||
const [abnormalTransferTotal, setAbnormalTransferTotal] = useState<number | null>(null);
|
||||
const [hotTab, setHotTab] = useState<HotPlayTab>("4D");
|
||||
const [playOptions, setPlayOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
const loadPlayOptions = useCallback(async () => {
|
||||
try {
|
||||
await getAdminPlayTypesLoadPromise(getAdminPlayTypes);
|
||||
setPlayOptions(
|
||||
getCachedAdminPlayTypes().map((item) => ({
|
||||
code: item.play_code,
|
||||
label:
|
||||
resolveAdminPlayTypeDisplayName(item.play_code, i18n.language, item) || item.play_code,
|
||||
})),
|
||||
);
|
||||
} catch {
|
||||
setPlayOptions([]);
|
||||
}
|
||||
}, [i18n.language]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadPlayOptions();
|
||||
}, [loadPlayOptions]);
|
||||
|
||||
const load = useCallback(async (isRefresh = false) => {
|
||||
if (isRefresh) {
|
||||
@@ -136,8 +159,8 @@ export function DashboardConsole(): ReactElement {
|
||||
setLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
setFinance(null);
|
||||
setCapabilities(null);
|
||||
setDrawPanel(null);
|
||||
setPendingReview(null);
|
||||
setDrawId(null);
|
||||
@@ -155,6 +178,7 @@ export function DashboardConsole(): ReactElement {
|
||||
setDrawId(d.resolved_draw.id);
|
||||
}
|
||||
|
||||
setCapabilities(d.capabilities);
|
||||
if (d.finance != null) {
|
||||
setFinance(d.finance);
|
||||
}
|
||||
@@ -169,15 +193,6 @@ export function DashboardConsole(): ReactElement {
|
||||
setSoldOutBuckets(d.risk.sold_out_buckets);
|
||||
}
|
||||
setAbnormalTransferTotal(d.abnormal_transfer_total);
|
||||
|
||||
const noticeParts: string[] = d.warnings.map((w) => w.message);
|
||||
if (d.resolved_draw != null && !d.capabilities.draw_finance_risk) {
|
||||
noticeParts.push(t("warnings.drawPermission"));
|
||||
}
|
||||
if (d.hall != null && !d.capabilities.wallet_transfer_view) {
|
||||
noticeParts.push(t("warnings.walletPermission"));
|
||||
}
|
||||
setNotice(noticeParts.length > 0 ? noticeParts.join(" ") : null);
|
||||
} catch (e) {
|
||||
const msg =
|
||||
e instanceof LotteryApiBizError ? e.message : t("warnings.loadFailed");
|
||||
@@ -196,6 +211,7 @@ export function DashboardConsole(): ReactElement {
|
||||
}, [load]);
|
||||
|
||||
const currency = finance?.currency_code ?? null;
|
||||
const canFinance = capabilities?.draw_finance_risk ?? false;
|
||||
const usagePct = riskCap > 0 ? (riskLocked / riskCap) * 100 : 0;
|
||||
|
||||
const hotRows = useMemo(() => topPoolsForTab(hotPoolSample, hotTab), [hotPoolSample, hotTab]);
|
||||
@@ -218,16 +234,6 @@ 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">
|
||||
@@ -242,7 +248,7 @@ export function DashboardConsole(): ReactElement {
|
||||
onClick={() => void load(true)}
|
||||
>
|
||||
<RefreshCw className={refreshing ? "size-4 animate-spin" : "size-4"} />
|
||||
{t("refresh")}
|
||||
{t("actions.refresh", { ns: "common" })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -254,69 +260,48 @@ export function DashboardConsole(): ReactElement {
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{notice && !error ? (
|
||||
<Alert className="border-sky-200 bg-sky-50 dark:border-sky-900/50 dark:bg-sky-950/30">
|
||||
{!loading && capabilities && !capabilities.draw_finance_risk ? (
|
||||
<Alert className="border-amber-200 bg-amber-50 dark:border-amber-900/60 dark:bg-amber-950/30">
|
||||
<AlertTitle>{t("notice")}</AlertTitle>
|
||||
<AlertDescription>{notice}</AlertDescription>
|
||||
<AlertDescription>{t("warnings.drawPermission")}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{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"
|
||||
/>
|
||||
{!loading && hall ? (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-border/80 bg-card px-4 py-3 shadow-sm">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Ticket className="size-5 text-primary" aria-hidden />
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{t("sections.currentDraw")}</p>
|
||||
<p className="font-mono text-lg font-semibold text-foreground">{hall.draw_no}</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("drawSequence", { sequence: hall.sequence_no ?? "—" })}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5 text-sm">
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 rounded-full",
|
||||
isOpenLike ? "bg-emerald-500" : "bg-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
{hallStatusLabel}
|
||||
</span>
|
||||
</div>
|
||||
{drawId != null ? (
|
||||
<Link
|
||||
href={`/admin/draws/${drawId}/finance`}
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "text-xs")}
|
||||
>
|
||||
{t("drawFinanceDetails")}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
<DashboardAnalyticsPanel enabled={canFinance} playOptions={playOptions} />
|
||||
|
||||
<h2 className="text-sm font-semibold tracking-wide text-muted-foreground">{t("sections.operations")}</h2>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2 xl:grid-cols-3">
|
||||
<Card className="border-border/80 shadow-sm xl:col-span-1">
|
||||
|
||||
260
src/modules/dashboard/dashboard-trend-charts.tsx
Normal file
260
src/modules/dashboard/dashboard-trend-charts.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactElement } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AdminDashboardAnalyticsPlayRow } from "@/types/api/admin-dashboard-analytics";
|
||||
import type { AdminReportDailyProfitRow } from "@/types/api/admin-reports";
|
||||
import type { DashboardAnalyticsMetric } from "@/types/api/admin-dashboard-analytics";
|
||||
|
||||
type MoneyFormatter = (minor: number, currency: string | null) => string;
|
||||
|
||||
function metricValue(row: AdminReportDailyProfitRow, metric: DashboardAnalyticsMetric): number {
|
||||
switch (metric) {
|
||||
case "bet":
|
||||
return row.total_bet_minor;
|
||||
case "payout":
|
||||
return row.total_payout_minor;
|
||||
case "profit":
|
||||
return row.approx_house_gross_minor;
|
||||
default:
|
||||
return row.total_bet_minor;
|
||||
}
|
||||
}
|
||||
|
||||
function playMetricValue(row: AdminDashboardAnalyticsPlayRow, metric: DashboardAnalyticsMetric): number {
|
||||
switch (metric) {
|
||||
case "bet":
|
||||
return row.total_bet_minor;
|
||||
case "payout":
|
||||
return row.total_payout_minor;
|
||||
case "profit":
|
||||
return row.approx_house_gross_minor;
|
||||
default:
|
||||
return row.total_bet_minor;
|
||||
}
|
||||
}
|
||||
|
||||
export function DailyTrendChart({
|
||||
series,
|
||||
metric,
|
||||
formatMoney,
|
||||
currency,
|
||||
}: {
|
||||
series: AdminReportDailyProfitRow[];
|
||||
metric: DashboardAnalyticsMetric;
|
||||
formatMoney: MoneyFormatter;
|
||||
currency: string | null;
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation("dashboard");
|
||||
|
||||
if (series.length === 0) {
|
||||
return <p className="py-10 text-center text-sm text-muted-foreground">{t("states.noData", { ns: "common" })}</p>;
|
||||
}
|
||||
|
||||
const maxBet = Math.max(...series.map((d) => d.total_bet_minor), 1);
|
||||
const maxPayout = Math.max(...series.map((d) => d.total_payout_minor), 1);
|
||||
const maxProfit = Math.max(...series.map((d) => Math.abs(d.approx_house_gross_minor)), 1);
|
||||
const labelEvery = series.length > 14 ? Math.ceil(series.length / 7) : 1;
|
||||
|
||||
const plotHeight = series.length <= 7 ? 200 : series.length <= 14 ? 220 : 240;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{metric === "overview" ? (
|
||||
<div className="flex shrink-0 flex-wrap gap-3 text-xs text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="size-2.5 rounded-sm bg-primary" />
|
||||
{t("chartLegend.bet")}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="size-2.5 rounded-sm bg-rose-500" />
|
||||
{t("chartLegend.payout")}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="size-2.5 rounded-sm bg-emerald-500" />
|
||||
{t("chartLegend.profit")}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className="flex items-end gap-1 overflow-x-auto rounded-md border border-border/60 bg-muted/20 px-2 pb-2 pt-3 sm:gap-1.5"
|
||||
style={{ height: plotHeight }}
|
||||
>
|
||||
{series.map((day, idx) => {
|
||||
const betH = (day.total_bet_minor / maxBet) * 100;
|
||||
const payoutH = (day.total_payout_minor / maxPayout) * 100;
|
||||
const profitRaw = day.approx_house_gross_minor;
|
||||
const profitH = (Math.abs(profitRaw) / maxProfit) * 100;
|
||||
const showLabel = idx % labelEvery === 0 || idx === series.length - 1;
|
||||
const shortDate = day.business_date.slice(5);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={day.business_date}
|
||||
className="flex min-w-[28px] flex-1 flex-col items-stretch justify-end gap-1 self-stretch"
|
||||
title={`${day.business_date}\n${t("todayBetTotal")}: ${formatMoney(day.total_bet_minor, currency)}\n${t("todayPayout")}: ${formatMoney(day.total_payout_minor, currency)}\n${t("todayProfit")}: ${formatMoney(day.approx_house_gross_minor, currency)}`}
|
||||
>
|
||||
<div className="flex w-full flex-1 items-end justify-center gap-0.5">
|
||||
{metric === "overview" ? (
|
||||
<>
|
||||
<div
|
||||
className="w-[30%] min-w-[4px] rounded-t-sm bg-primary/90 transition-all"
|
||||
style={{ height: `${Math.max(betH, day.total_bet_minor > 0 ? 4 : 0)}%` }}
|
||||
/>
|
||||
<div
|
||||
className="w-[30%] min-w-[4px] rounded-t-sm bg-rose-500/90 transition-all"
|
||||
style={{ height: `${Math.max(payoutH, day.total_payout_minor > 0 ? 4 : 0)}%` }}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"w-[30%] min-w-[4px] rounded-t-sm transition-all",
|
||||
profitRaw >= 0 ? "bg-emerald-500/90" : "bg-amber-500/90",
|
||||
)}
|
||||
style={{ height: `${Math.max(profitH, profitRaw !== 0 ? 4 : 0)}%` }}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"w-[70%] min-w-[6px] max-w-[20px] rounded-t-md transition-all",
|
||||
metric === "payout" && "bg-rose-500/90",
|
||||
metric === "profit" && (profitRaw >= 0 ? "bg-emerald-500/90" : "bg-amber-500/90"),
|
||||
metric === "bet" && "bg-primary/90",
|
||||
)}
|
||||
style={{
|
||||
height: `${Math.max(
|
||||
(metricValue(day, metric) / (metric === "bet" ? maxBet : metric === "payout" ? maxPayout : maxProfit)) * 100,
|
||||
metricValue(day, metric) !== 0 ? 6 : 0,
|
||||
)}%`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 text-center text-[10px] tabular-nums text-muted-foreground",
|
||||
!showLabel && "invisible",
|
||||
)}
|
||||
>
|
||||
{shortDate}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlayBreakdownChart({
|
||||
rows,
|
||||
metric,
|
||||
formatMoney,
|
||||
currency,
|
||||
playLabel,
|
||||
}: {
|
||||
rows: AdminDashboardAnalyticsPlayRow[];
|
||||
metric: DashboardAnalyticsMetric;
|
||||
formatMoney: MoneyFormatter;
|
||||
currency: string | null;
|
||||
playLabel: (code: string, dimension: number) => string;
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation("dashboard");
|
||||
|
||||
if (rows.length === 0) {
|
||||
return <p className="py-10 text-center text-sm text-muted-foreground">{t("analytics.noPlayData")}</p>;
|
||||
}
|
||||
|
||||
const max = Math.max(...rows.map((r) => Math.abs(playMetricValue(r, metric === "overview" ? "bet" : metric))), 1);
|
||||
const activeMetric = metric === "overview" ? "bet" : metric;
|
||||
|
||||
return (
|
||||
<ul className="space-y-2.5">
|
||||
{rows.map((row) => {
|
||||
const value = playMetricValue(row, activeMetric);
|
||||
const pct = (Math.abs(value) / max) * 100;
|
||||
const label = playLabel(row.play_code, row.dimension);
|
||||
|
||||
return (
|
||||
<li key={`${row.play_code}-${row.dimension}`}>
|
||||
<div className="mb-1 flex items-center justify-between gap-2 text-sm">
|
||||
<span className="truncate font-medium text-foreground">{label}</span>
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground">{formatMoney(value, currency)}</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-all",
|
||||
activeMetric === "payout" && "bg-rose-500",
|
||||
activeMetric === "profit" && (value >= 0 ? "bg-emerald-500" : "bg-amber-500"),
|
||||
activeMetric === "bet" && "bg-primary",
|
||||
)}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
{metric === "overview" ? (
|
||||
<p className="mt-0.5 line-clamp-1 text-[11px] text-muted-foreground">
|
||||
{t("playBreakdownHint", {
|
||||
payout: formatMoney(row.total_payout_minor, currency),
|
||||
profit: formatMoney(row.approx_house_gross_minor, currency),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export function PeriodCompareStrip({
|
||||
series,
|
||||
formatMoney,
|
||||
currency,
|
||||
}: {
|
||||
series: AdminReportDailyProfitRow[];
|
||||
formatMoney: MoneyFormatter;
|
||||
currency: string | null;
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const totalBet = series.reduce((s, d) => s + d.total_bet_minor, 0);
|
||||
const totalPayout = series.reduce((s, d) => s + d.total_payout_minor, 0);
|
||||
const totalProfit = series.reduce((s, d) => s + d.approx_house_gross_minor, 0);
|
||||
const max = Math.max(totalBet, totalPayout, Math.abs(totalProfit), 1);
|
||||
|
||||
const items = [
|
||||
{ key: "bet", label: t("chartLegend.bet"), value: totalBet, className: "bg-primary" },
|
||||
{ key: "payout", label: t("chartLegend.payout"), value: totalPayout, className: "bg-rose-500" },
|
||||
{
|
||||
key: "profit",
|
||||
label: t("chartLegend.profit"),
|
||||
value: totalProfit,
|
||||
className: totalProfit >= 0 ? "bg-emerald-500" : "bg-amber-500",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{items.map((item) => (
|
||||
<div key={item.key} className="rounded-lg border border-border/60 bg-muted/20 px-3 py-3">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<span className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className={cn("size-2.5 rounded-sm", item.className)} />
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="text-sm font-semibold tabular-nums">{formatMoney(item.value, currency)}</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={cn("h-full rounded-full", item.className)}
|
||||
style={{ width: `${(Math.abs(item.value) / max) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -123,8 +123,8 @@ export function FinanceStructureChart({
|
||||
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: "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);
|
||||
|
||||
@@ -176,9 +176,17 @@ export function PayoutCompositionChart({
|
||||
}
|
||||
|
||||
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-chart-2" },
|
||||
{ label: t("jackpotPayout"), value: jackpot, pct: 100 - winPct, className: "bg-chart-4" },
|
||||
{ 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,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -186,7 +194,7 @@ export function PayoutCompositionChart({
|
||||
<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)`,
|
||||
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%)",
|
||||
}}
|
||||
@@ -203,7 +211,10 @@ export function PayoutCompositionChart({
|
||||
</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
|
||||
className="h-full rounded-full"
|
||||
style={{ width: `${item.pct}%`, background: item.color }}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
@@ -249,12 +260,12 @@ export function HotUsageBars({ rows }: { rows: AdminRiskPoolRow[] }): ReactEleme
|
||||
|
||||
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 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);
|
||||
|
||||
@@ -307,7 +318,7 @@ export function SoldOutRing({ buckets }: { buckets: SoldOutBuckets }): ReactElem
|
||||
<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 }} />
|
||||
<span className={cn("size-2.5 rounded-sm", e.swatch)} />
|
||||
{e.label}
|
||||
</span>
|
||||
<span className="font-medium tabular-nums">
|
||||
@@ -381,6 +392,25 @@ export function SettlementStatusChart({
|
||||
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";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ul className="space-y-3">
|
||||
{entries.map(([status, count]) => (
|
||||
@@ -391,7 +421,7 @@ export function SettlementStatusChart({
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary/80"
|
||||
className={cn("h-full rounded-full transition-all", barTone(status))}
|
||||
style={{ width: `${max > 0 ? (count / max) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user