feat(api, i18n): add agent_node_id to various admin queries and enhance multi-language support
Introduced the agent_node_id field in AdminDrawListQuery, AdminPlayerListQuery, AdminSettlementBatchListQuery, TicketItemsListQuery, and TransferOrderListQuery to improve filtering capabilities. Updated the admin-breadcrumb and admin-sidebar components to include new translations for agent-related terms in English, Nepali, and Chinese, enhancing the overall user experience and multi-language support across the admin interface.
This commit is contained in:
@@ -21,6 +21,7 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { getAdminRequestLocale } from "@/lib/admin-locale";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DashboardKpiCard } from "@/modules/dashboard/dashboard-visuals";
|
||||
import { DASHBOARD_CHART_COLORS } from "@/modules/dashboard/dashboard-chart-config";
|
||||
import {
|
||||
DailyTrendChart,
|
||||
PlayBreakdownChart,
|
||||
@@ -356,6 +357,112 @@ export function DashboardPlayRankingCard({
|
||||
);
|
||||
}
|
||||
|
||||
export function DashboardAgentRankingCard({
|
||||
analytics,
|
||||
}: {
|
||||
analytics: DashboardAnalyticsState;
|
||||
}): ReactNode {
|
||||
const { t } = useTranslation(["dashboard", "common"]);
|
||||
const {
|
||||
enabled,
|
||||
rankingMetric,
|
||||
loading,
|
||||
topAgentRows,
|
||||
currency,
|
||||
formatMoney,
|
||||
formatSignedMoney,
|
||||
} = analytics;
|
||||
|
||||
if (!enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const metricValue = (row: (typeof topAgentRows)[number]): number => {
|
||||
if (rankingMetric === "payout") {
|
||||
return row.total_payout_minor;
|
||||
}
|
||||
if (rankingMetric === "profit") {
|
||||
return row.approx_house_gross_minor;
|
||||
}
|
||||
return row.total_bet_minor;
|
||||
};
|
||||
|
||||
const maxAbs = Math.max(1, ...topAgentRows.map((r) => Math.abs(metricValue(r))));
|
||||
|
||||
const formatRowValue = (row: (typeof topAgentRows)[number]): string => {
|
||||
const v = metricValue(row);
|
||||
if (rankingMetric === "profit") {
|
||||
return formatSignedMoney(v, currency);
|
||||
}
|
||||
return formatMoney(v, currency);
|
||||
};
|
||||
|
||||
const barColor = (row: (typeof topAgentRows)[number]): string => {
|
||||
if (rankingMetric === "bet") {
|
||||
return DASHBOARD_CHART_COLORS.primary;
|
||||
}
|
||||
if (rankingMetric === "payout") {
|
||||
return DASHBOARD_CHART_COLORS.rose;
|
||||
}
|
||||
return row.approx_house_gross_minor >= 0 ? DASHBOARD_CHART_COLORS.success : DASHBOARD_CHART_COLORS.warning;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="admin-list-card flex min-w-0 flex-col overflow-hidden py-0">
|
||||
<CardHeader className="space-y-2 border-b border-border/60 px-4 py-3">
|
||||
<CardTitle className="text-sm font-semibold">{t("analytics.agentRanking")}</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(`analytics.rankingMetrics.${rankingMetric}`)}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="min-w-0 flex-1 overflow-hidden px-3 py-3">
|
||||
{loading ? (
|
||||
<Skeleton className="h-[210px] w-full" />
|
||||
) : topAgentRows.length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
{topAgentRows.map((row, idx) => {
|
||||
const v = metricValue(row);
|
||||
const pct = (Math.abs(v) / maxAbs) * 100;
|
||||
const color = barColor(row);
|
||||
return (
|
||||
<div key={row.agent_node_id} className="rounded-lg bg-muted/20 px-2 py-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<span className="mt-0.5 w-5 shrink-0 text-center text-[11px] font-semibold text-muted-foreground">
|
||||
#{idx + 1}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-xs font-medium">{row.agent_name || "-"}</p>
|
||||
<p className="truncate text-[11px] text-muted-foreground">{row.agent_code || ""}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-right text-xs font-semibold tabular-nums">
|
||||
{formatRowValue(row)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 h-2 overflow-hidden rounded-full bg-muted/30">
|
||||
<div
|
||||
className="h-full rounded-full"
|
||||
style={{
|
||||
width: `${Math.max(2, pct)}%`,
|
||||
backgroundColor: color,
|
||||
opacity: 0.35,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-10 text-center text-sm text-muted-foreground">{t("analytics.noAgentData")}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/** 单列堆叠布局(兼容旧用法) */
|
||||
export function DashboardAnalyticsPanel({
|
||||
enabled,
|
||||
|
||||
@@ -20,14 +20,12 @@ import {
|
||||
|
||||
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 { useCachedPlayTypeOptions } from "@/hooks/use-cached-play-type-options";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import {
|
||||
DashboardAnalyticsMain,
|
||||
DashboardAgentRankingCard,
|
||||
DashboardPlayRankingCard,
|
||||
} from "@/modules/dashboard/dashboard-analytics-panel";
|
||||
import { DashboardCurrentDrawCard } from "@/modules/dashboard/dashboard-current-draw-card";
|
||||
@@ -50,7 +48,11 @@ import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
||||
import { adminWeekdayKeyForDate, formatAdminCalendarToday } from "@/lib/admin-datetime";
|
||||
import { normalizeAdminLanguage } from "@/i18n";
|
||||
import { getAdminRequestLocale } from "@/lib/admin-locale";
|
||||
import { formatAdminMinorUnits, getAdminCurrencyDecimalPlaces } from "@/lib/money";
|
||||
import {
|
||||
coerceAdminMinor,
|
||||
formatAdminMinorUnits,
|
||||
getAdminCurrencyDecimalPlaces,
|
||||
} from "@/lib/money";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type {
|
||||
@@ -66,9 +68,10 @@ import type { DrawCurrentSnapshot } from "@/types/api/public-draw";
|
||||
type HotPlayTab = "4D" | "3D" | "2D" | "special";
|
||||
|
||||
function formatMoneyMinor(minor: number, currencyCode: string | null): string {
|
||||
const safeMinor = coerceAdminMinor(minor);
|
||||
const code = (currencyCode ?? "NPR").toUpperCase();
|
||||
const decimals = getAdminCurrencyDecimalPlaces(code);
|
||||
const major = minor / 10 ** decimals;
|
||||
const major = safeMinor / 10 ** decimals;
|
||||
try {
|
||||
return new Intl.NumberFormat(getAdminRequestLocale(), {
|
||||
style: "currency",
|
||||
@@ -77,7 +80,7 @@ function formatMoneyMinor(minor: number, currencyCode: string | null): string {
|
||||
maximumFractionDigits: decimals,
|
||||
}).format(major);
|
||||
} catch {
|
||||
return formatAdminMinorUnits(minor, code, decimals);
|
||||
return formatAdminMinorUnits(safeMinor, code, decimals);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,28 +165,8 @@ export function DashboardConsole(): ReactElement {
|
||||
const [hotPoolSample, setHotPoolSample] = useState<AdminRiskPoolRow[]>([]);
|
||||
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(() => {
|
||||
queueMicrotask(() => {
|
||||
void loadPlayOptions();
|
||||
});
|
||||
}, [loadPlayOptions]);
|
||||
const playOptions = useCachedPlayTypeOptions();
|
||||
const tRef = useTranslationRef(["dashboard", "common"]);
|
||||
|
||||
const load = useCallback(async (isRefresh = false) => {
|
||||
if (isRefresh) {
|
||||
@@ -230,27 +213,30 @@ export function DashboardConsole(): ReactElement {
|
||||
setAbnormalTransferTotal(d.abnormal_transfer_total);
|
||||
} catch (e) {
|
||||
const msg =
|
||||
e instanceof LotteryApiBizError ? e.message : t("warnings.loadFailed");
|
||||
e instanceof LotteryApiBizError ? e.message : tRef.current("warnings.loadFailed");
|
||||
setError(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [t]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
void load(false);
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [load]);
|
||||
useAsyncEffect(() => {
|
||||
void load(false);
|
||||
}, []);
|
||||
|
||||
const currency =
|
||||
lifetimeFinance?.currency_code ?? finance?.currency_code ?? null;
|
||||
const canFinance = capabilities?.draw_finance_risk ?? false;
|
||||
const platformLocked = platformRisk?.locked_amount ?? 0;
|
||||
const platformCap = platformRisk?.cap_amount ?? 0;
|
||||
const platformUsagePct = platformRisk?.usage_percent ?? 0;
|
||||
const platformLocked = coerceAdminMinor(platformRisk?.locked_amount);
|
||||
const platformCap = coerceAdminMinor(platformRisk?.cap_amount);
|
||||
const rawPlatformUsagePct = platformRisk?.usage_percent;
|
||||
const platformUsagePct =
|
||||
typeof rawPlatformUsagePct === "number" && Number.isFinite(rawPlatformUsagePct)
|
||||
? Math.min(100, Math.max(0, rawPlatformUsagePct))
|
||||
: platformCap > 0
|
||||
? (platformLocked / platformCap) * 100
|
||||
: 0;
|
||||
|
||||
const hotRows = useMemo(() => topPoolsForTab(hotPoolSample, hotTab), [hotPoolSample, hotTab]);
|
||||
|
||||
@@ -359,10 +345,16 @@ export function DashboardConsole(): ReactElement {
|
||||
href="/admin/risk"
|
||||
title={t("riskCapUsage")}
|
||||
value={`${platformUsagePct.toFixed(1)}%`}
|
||||
subtitle={t("platformLockedAndCap", {
|
||||
locked: formatMoneyMinor(platformLocked, currency),
|
||||
cap: formatMoneyMinor(platformCap, currency),
|
||||
})}
|
||||
subtitle={
|
||||
platformCap > 0
|
||||
? t("platformLockedAndCap", {
|
||||
locked: formatMoneyMinor(platformLocked, currency),
|
||||
cap: formatMoneyMinor(platformCap, currency),
|
||||
})
|
||||
: t("platformCapNotConfigured", {
|
||||
locked: formatMoneyMinor(platformLocked, currency),
|
||||
})
|
||||
}
|
||||
actionLabel={t("occupancyDetails")}
|
||||
icon={<Shield className="size-5" aria-hidden />}
|
||||
accent={
|
||||
@@ -542,6 +534,7 @@ export function DashboardConsole(): ReactElement {
|
||||
{showAnalytics ? (
|
||||
<aside className="flex min-w-0 flex-col gap-4 xl:col-span-4">
|
||||
<DashboardPlayRankingCard analytics={analytics} />
|
||||
<DashboardAgentRankingCard analytics={analytics} />
|
||||
|
||||
<Card className="admin-list-card min-w-0 py-0">
|
||||
<CardHeader className="border-b border-border/60 px-4 py-3 pb-0">
|
||||
|
||||
@@ -29,6 +29,11 @@ import {
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from "@/components/ui/chart";
|
||||
import {
|
||||
coerceAdminMinor,
|
||||
formatAdminMinorDecimal,
|
||||
getAdminCurrencyDecimalPlaces,
|
||||
} from "@/lib/money";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
buildBatchProgressConfig,
|
||||
@@ -53,6 +58,74 @@ export type SoldOutBuckets = AdminDashboardSoldOutBuckets;
|
||||
|
||||
type MoneyFormatter = (minor: number, currency: string | null) => string;
|
||||
|
||||
type DashboardFinanceMetricCell = {
|
||||
key: string;
|
||||
label: string;
|
||||
amount: number;
|
||||
emphasize: boolean;
|
||||
};
|
||||
|
||||
/** KPI 卡片底部三列:仅数字(币种见卡片主值),过长时省略号 + hover 看全称 */
|
||||
function formatDashboardMetricAmount(
|
||||
minor: number,
|
||||
currencyCode: string | null,
|
||||
formatMoney: MoneyFormatter,
|
||||
): { display: string; title: string } {
|
||||
const safeMinor = coerceAdminMinor(minor);
|
||||
const code = (currencyCode ?? "NPR").toUpperCase();
|
||||
const decimals = getAdminCurrencyDecimalPlaces(code);
|
||||
return {
|
||||
display: formatAdminMinorDecimal(safeMinor, code, decimals),
|
||||
title: formatMoney(safeMinor, currencyCode),
|
||||
};
|
||||
}
|
||||
|
||||
function DashboardFinanceMetricCells({
|
||||
cells,
|
||||
currency,
|
||||
formatMoney,
|
||||
}: {
|
||||
cells: readonly DashboardFinanceMetricCell[];
|
||||
currency: string | null;
|
||||
formatMoney: MoneyFormatter;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{cells.map((cell) => {
|
||||
const { display, title } = formatDashboardMetricAmount(
|
||||
cell.amount,
|
||||
currency,
|
||||
formatMoney,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={cell.key}
|
||||
className={cn(
|
||||
"min-w-0 rounded-lg px-1 py-2 ring-1",
|
||||
cell.emphasize
|
||||
? "bg-primary/6 ring-primary/15"
|
||||
: "bg-muted/30 ring-border/50",
|
||||
)}
|
||||
>
|
||||
<p className="line-clamp-2 text-center text-[10px] leading-tight text-muted-foreground">
|
||||
{cell.label}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-1 truncate text-center text-[10px] font-bold tabular-nums leading-tight",
|
||||
cell.emphasize ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
title={title}
|
||||
>
|
||||
{display}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function usageBarFill(pct: number): string {
|
||||
if (pct >= 95) {
|
||||
return DASHBOARD_CHART_COLORS.rose;
|
||||
@@ -485,10 +558,11 @@ export function PayoutPanelSnapshot({
|
||||
}): 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;
|
||||
const bet = coerceAdminMinor(finance.total_bet_minor);
|
||||
const win = coerceAdminMinor(finance.total_win_payout_minor);
|
||||
const jackpot = coerceAdminMinor(finance.total_jackpot_win_minor);
|
||||
const payout = coerceAdminMinor(finance.total_payout_minor);
|
||||
const hasPayout = payout > 0 || win + jackpot > 0;
|
||||
|
||||
if (bet <= 0 && !hasPayout) {
|
||||
return <DashboardChartEmpty message={t("noFinanceActivity")} compact />;
|
||||
@@ -502,29 +576,7 @@ export function PayoutPanelSnapshot({
|
||||
|
||||
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>
|
||||
<DashboardFinanceMetricCells cells={cells} currency={currency} formatMoney={formatMoney} />
|
||||
{hasPayout ? (
|
||||
<PayoutCompositionChart finance={finance} formatMoney={formatMoney} compact />
|
||||
) : (
|
||||
@@ -983,7 +1035,10 @@ export function ResultBatchQueueSummary({
|
||||
compact?: boolean;
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const { pending_review_total, pending_draw_count, published_total, batch_total } = queue;
|
||||
const pendingReviewTotal = coerceAdminMinor(queue.pending_review_total);
|
||||
const pendingDrawCount = coerceAdminMinor(queue.pending_draw_count);
|
||||
const publishedTotal = coerceAdminMinor(queue.published_total);
|
||||
const batchTotal = coerceAdminMinor(queue.batch_total);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-2 text-center">
|
||||
@@ -994,7 +1049,7 @@ export function ResultBatchQueueSummary({
|
||||
compact ? "text-lg" : "text-2xl",
|
||||
)}
|
||||
>
|
||||
{pending_review_total}
|
||||
{pendingReviewTotal}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[10px] text-muted-foreground">{t("batchPending")}</p>
|
||||
</div>
|
||||
@@ -1005,18 +1060,16 @@ export function ResultBatchQueueSummary({
|
||||
compact ? "text-lg" : "text-2xl",
|
||||
)}
|
||||
>
|
||||
{published_total}
|
||||
{publishedTotal}
|
||||
</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")}>
|
||||
{batch_total}
|
||||
{pendingDrawCount > 0 ? pendingDrawCount : batchTotal}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[10px] text-muted-foreground">
|
||||
{pending_draw_count > 0
|
||||
? t("batchPendingDrawsCount", { count: pending_draw_count })
|
||||
: t("batchTotal")}
|
||||
{pendingDrawCount > 0 ? t("batchPendingDraws") : t("batchTotal")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1032,10 +1085,14 @@ export function PlatformLifetimePayoutSnapshot({
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const currency = finance.currency_code;
|
||||
const bet = finance.total_bet_minor;
|
||||
const win = finance.total_win_minor;
|
||||
const jackpot = finance.total_jackpot_minor;
|
||||
const hasPayout = win + jackpot > 0;
|
||||
const bet = coerceAdminMinor(finance.total_bet_minor);
|
||||
const payout = coerceAdminMinor(finance.total_payout_minor);
|
||||
let win = coerceAdminMinor(finance.total_win_minor);
|
||||
let jackpot = coerceAdminMinor(finance.total_jackpot_minor);
|
||||
if (payout > 0 && win + jackpot === 0) {
|
||||
win = payout;
|
||||
}
|
||||
const hasPayout = payout > 0 || win + jackpot > 0;
|
||||
|
||||
if (bet <= 0 && !hasPayout) {
|
||||
return <DashboardChartEmpty message={t("platformNoFinanceActivity")} compact />;
|
||||
@@ -1049,29 +1106,7 @@ export function PlatformLifetimePayoutSnapshot({
|
||||
|
||||
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>
|
||||
<DashboardFinanceMetricCells cells={cells} currency={currency} formatMoney={formatMoney} />
|
||||
{!hasPayout ? (
|
||||
<p className="rounded-lg bg-muted/25 px-2 py-2 text-center text-[11px] text-muted-foreground ring-1 ring-border/40">
|
||||
{t("platformNoPayoutYet")}
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { format, subDays } from "date-fns";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getAdminDashboardAnalytics } from "@/api/admin-dashboard";
|
||||
import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog";
|
||||
import { getAdminRequestLocale } from "@/lib/admin-locale";
|
||||
import { formatAdminMinorUnits, getAdminCurrencyDecimalPlaces } from "@/lib/money";
|
||||
import {
|
||||
coerceAdminMinor,
|
||||
formatAdminMinorUnits,
|
||||
getAdminCurrencyDecimalPlaces,
|
||||
} from "@/lib/money";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type {
|
||||
AdminDashboardAnalyticsData,
|
||||
AdminDashboardAnalyticsAgentRow,
|
||||
DashboardAnalyticsMetric,
|
||||
DashboardAnalyticsPeriod,
|
||||
} from "@/types/api/admin-dashboard-analytics";
|
||||
@@ -27,9 +34,10 @@ export const DASHBOARD_ANALYTICS_PERIODS: DashboardAnalyticsPeriod[] = [
|
||||
export const DASHBOARD_RANKING_METRICS: DashboardAnalyticsMetric[] = ["bet", "payout", "profit"];
|
||||
|
||||
export function formatDashboardMoneyMinor(minor: number, currencyCode: string | null): string {
|
||||
const safeMinor = coerceAdminMinor(minor);
|
||||
const code = (currencyCode ?? "NPR").toUpperCase();
|
||||
const decimals = getAdminCurrencyDecimalPlaces(code);
|
||||
const major = minor / 10 ** decimals;
|
||||
const major = safeMinor / 10 ** decimals;
|
||||
try {
|
||||
return new Intl.NumberFormat(getAdminRequestLocale(), {
|
||||
style: "currency",
|
||||
@@ -38,7 +46,7 @@ export function formatDashboardMoneyMinor(minor: number, currencyCode: string |
|
||||
maximumFractionDigits: decimals,
|
||||
}).format(major);
|
||||
} catch {
|
||||
return formatAdminMinorUnits(minor, code, decimals);
|
||||
return formatAdminMinorUnits(safeMinor, code, decimals);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +66,7 @@ export function useDashboardAnalytics({
|
||||
playOptions: { code: string; label: string }[];
|
||||
}) {
|
||||
const { t } = useTranslation(["dashboard", "common"]);
|
||||
const tRef = useTranslationRef(["dashboard", "common"]);
|
||||
const playLabel = useAdminPlayCodeLabel();
|
||||
|
||||
const [period, setPeriod] = useState<DashboardAnalyticsPeriod>("last_7_days");
|
||||
@@ -94,19 +103,18 @@ export function useDashboardAnalytics({
|
||||
const needsAuthSync =
|
||||
raw.includes("admin.dashboard.analytics") || raw.includes("资源未配置");
|
||||
setError(
|
||||
needsAuthSync ? t("warnings.apiResourceMissing") : raw || t("warnings.loadFailed"),
|
||||
needsAuthSync
|
||||
? tRef.current("warnings.apiResourceMissing")
|
||||
: raw || tRef.current("warnings.loadFailed"),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [enabled, period, playCode, customFrom, customTo, t]);
|
||||
}, [enabled, period, playCode, customFrom, customTo]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
void load();
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [load]);
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [enabled, period, playCode, customFrom, customTo]);
|
||||
|
||||
const currency = data?.currency_code ?? null;
|
||||
const summary = data?.summary;
|
||||
@@ -152,6 +160,28 @@ export function useDashboardAnalytics({
|
||||
return rows.slice(0, 5);
|
||||
}, [data, rankingMetric]);
|
||||
|
||||
const metricAgentValue = useCallback(
|
||||
(row: AdminDashboardAnalyticsAgentRow): number => {
|
||||
if (rankingMetric === "payout") {
|
||||
return row.total_payout_minor;
|
||||
}
|
||||
if (rankingMetric === "profit") {
|
||||
return row.approx_house_gross_minor;
|
||||
}
|
||||
return row.total_bet_minor;
|
||||
},
|
||||
[rankingMetric],
|
||||
);
|
||||
|
||||
const topAgentRows = useMemo(() => {
|
||||
if (!data) {
|
||||
return [];
|
||||
}
|
||||
const rows = [...data.agent_breakdown];
|
||||
rows.sort((a, b) => metricAgentValue(b) - metricAgentValue(a));
|
||||
return rows.slice(0, 5);
|
||||
}, [data, metricAgentValue]);
|
||||
|
||||
const sparklines = useMemo(() => {
|
||||
const series = data?.daily_series ?? [];
|
||||
return {
|
||||
@@ -183,6 +213,7 @@ export function useDashboardAnalytics({
|
||||
playOptions,
|
||||
resolvePlayLabel,
|
||||
topPlayRows,
|
||||
topAgentRows,
|
||||
sparklines,
|
||||
formatMoney: formatDashboardMoneyMinor,
|
||||
formatSignedMoney: formatDashboardSignedMoneyMinor,
|
||||
|
||||
Reference in New Issue
Block a user