feat(dashboard, i18n): enhance agent dashboard and localization support
Updated the agent dashboard to include new metrics for today's bets and payouts, improving visibility for users. Enhanced localization files with additional hints and labels for better user experience across English, Nepali, and Chinese. Introduced new functions for formatting business dates and improved the handling of analytics permissions in the dashboard components.
This commit is contained in:
@@ -10,40 +10,45 @@ import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { useCachedPlayTypeOptions } from "@/hooks/use-cached-play-type-options";
|
||||
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { PRD_DASHBOARD_ANALYTICS_ACCESS_ANY } from "@/lib/admin-prd";
|
||||
import { normalizeAdminLanguage } from "@/i18n";
|
||||
import { adminWeekdayKeyForDate, formatAdminCalendarToday } from "@/lib/admin-datetime";
|
||||
import { signedMoneyClass } from "@/lib/admin-signed-money";
|
||||
import { adminWeekdayKeyForDate, formatAdminBusinessDateIso, formatAdminCalendarToday } from "@/lib/admin-datetime";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { DashboardCurrentDrawCard } from "@/modules/dashboard/dashboard-current-draw-card";
|
||||
import { DashboardAnalyticsPanel } from "@/modules/dashboard/dashboard-analytics-panel";
|
||||
import { DashboardKpiCard } from "@/modules/dashboard/dashboard-visuals";
|
||||
import {
|
||||
DashboardKpiCard,
|
||||
DashboardScopeMetric,
|
||||
DashboardSignedStatRow,
|
||||
DashboardStatRow,
|
||||
} from "@/modules/dashboard/dashboard-visuals";
|
||||
import {
|
||||
formatDashboardCreditMajor,
|
||||
formatDashboardMoneyMinor,
|
||||
formatDashboardSignedMoneyMinor,
|
||||
} from "@/modules/dashboard/use-dashboard-analytics";
|
||||
import type { AdminDashboardAgentOverview } from "@/types/api/admin-dashboard";
|
||||
import type { AdminDashboardAgentOverview, AdminDashboardWarning } from "@/types/api/admin-dashboard";
|
||||
import type { DrawCurrentSnapshot } from "@/types/api/public-draw";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
|
||||
function AgentMetric({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<div className="rounded-lg border bg-muted/30 px-3 py-2.5">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className="mt-1 text-base font-semibold tabular-nums text-foreground">{value}</p>
|
||||
</div>
|
||||
);
|
||||
function buildTodayBetHint(
|
||||
businessDate: string,
|
||||
latestBetAt: string | null,
|
||||
t: (key: string, opts?: Record<string, unknown>) => string,
|
||||
formatDt: (iso: string) => string,
|
||||
): string {
|
||||
const dateHint = t("todayBusinessDateHint", { date: businessDate });
|
||||
if (latestBetAt) {
|
||||
return `${dateHint} · ${t("agent.latestBetAt", { time: formatDt(latestBetAt) })}`;
|
||||
}
|
||||
|
||||
return `${dateHint} · ${t("agent.noBetToday")}`;
|
||||
}
|
||||
|
||||
export function AgentDashboardConsole(): ReactElement {
|
||||
@@ -52,6 +57,9 @@ export function AgentDashboardConsole(): ReactElement {
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
const profile = useAdminProfile();
|
||||
const agent = profile?.agent ?? null;
|
||||
const permissions = useMemo(() => profile?.permissions ?? [], [profile?.permissions]);
|
||||
const businessDateToday = useMemo(() => formatAdminBusinessDateIso(), []);
|
||||
|
||||
const todayLabel = useMemo(() => {
|
||||
const locale = normalizeAdminLanguage(i18n.resolvedLanguage ?? i18n.language);
|
||||
const weekday = t(`date.weekdays.${adminWeekdayKeyForDate()}`, { ns: "common" });
|
||||
@@ -65,10 +73,10 @@ export function AgentDashboardConsole(): ReactElement {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [apiWarnings, setApiWarnings] = useState<AdminDashboardWarning[]>([]);
|
||||
const [hall, setHall] = useState<DrawCurrentSnapshot | null>(null);
|
||||
const [drawId, setDrawId] = useState<number | null>(null);
|
||||
const [overview, setOverview] = useState<AdminDashboardAgentOverview | null>(null);
|
||||
const [canFinance, setCanFinance] = useState(false);
|
||||
|
||||
const analyticsScope = useMemo(
|
||||
() => ({
|
||||
@@ -78,6 +86,8 @@ export function AgentDashboardConsole(): ReactElement {
|
||||
[agent?.id, agent?.site_code],
|
||||
);
|
||||
|
||||
const canAnalytics = adminHasAnyPermission(permissions, [...PRD_DASHBOARD_ANALYTICS_ACCESS_ANY]);
|
||||
|
||||
const load = useCallback(async (isRefresh = false) => {
|
||||
if (isRefresh) {
|
||||
setRefreshing(true);
|
||||
@@ -90,7 +100,7 @@ export function AgentDashboardConsole(): ReactElement {
|
||||
const d = await getAdminDashboard();
|
||||
setHall(d.hall);
|
||||
setOverview(d.agent_overview);
|
||||
setCanFinance(d.capabilities.draw_finance_risk);
|
||||
setApiWarnings(d.warnings ?? []);
|
||||
if (d.resolved_draw != null) {
|
||||
setDrawId(d.resolved_draw.id);
|
||||
} else {
|
||||
@@ -110,8 +120,7 @@ export function AgentDashboardConsole(): ReactElement {
|
||||
void load(false);
|
||||
}, []);
|
||||
|
||||
const currency = "NPR";
|
||||
const displayCurrency = overview?.currency_code ?? currency;
|
||||
const displayCurrency = overview?.currency_code ?? "NPR";
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 w-full max-w-none flex-col gap-5">
|
||||
@@ -144,31 +153,36 @@ export function AgentDashboardConsole(): ReactElement {
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{!loading && apiWarnings.length > 0 ? (
|
||||
<Alert className="border-amber-200 bg-amber-50 dark:border-amber-900/60 dark:bg-amber-950/30">
|
||||
<AlertTitle>{t("notice")}</AlertTitle>
|
||||
<AlertDescription>{apiWarnings.map((w) => w.message).join(" ")}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-24 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : overview ? (
|
||||
<section className="space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<DashboardKpiCard
|
||||
label={t("agent.todayBet")}
|
||||
value={formatDashboardMoneyMinor(overview.today_bet_minor, displayCurrency)}
|
||||
icon={<TrendingUp className="size-4" />}
|
||||
hint={
|
||||
overview.latest_bet_at
|
||||
? t("agent.latestBetAt", { time: formatDt(overview.latest_bet_at) })
|
||||
: t("agent.noBetToday")
|
||||
}
|
||||
hint={buildTodayBetHint(businessDateToday, overview.latest_bet_at, t, formatDt)}
|
||||
/>
|
||||
<DashboardKpiCard
|
||||
label={t("agent.todayShareProfit")}
|
||||
value={formatDashboardSignedMoneyMinor(overview.today_profit_minor, displayCurrency)}
|
||||
signedAmountMinor={overview.today_profit_minor}
|
||||
currencyCode={displayCurrency}
|
||||
icon={<BarChart3 className="size-4" />}
|
||||
hint={t("agent.shareRate", { rate: overview.total_share_rate })}
|
||||
valueClassName={signedMoneyClass(overview.today_profit_minor, true)}
|
||||
hint={`${t("agent.shareRate", { rate: overview.total_share_rate })} · ${t("todayPayoutHint", {
|
||||
amount: formatDashboardMoneyMinor(overview.today_payout_minor, displayCurrency),
|
||||
})}`}
|
||||
/>
|
||||
<DashboardKpiCard
|
||||
label={t("agent.activePlayersToday")}
|
||||
@@ -193,7 +207,7 @@ export function AgentDashboardConsole(): ReactElement {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<p className="text-2xl font-semibold tabular-nums">
|
||||
<p className="break-all text-2xl font-semibold tabular-nums leading-tight">
|
||||
{formatDashboardCreditMajor(overview.credit_limit, displayCurrency)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
@@ -202,19 +216,15 @@ export function AgentDashboardConsole(): ReactElement {
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<AgentMetric
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<DashboardScopeMetric
|
||||
label={t("agent.creditAllocatedLabel")}
|
||||
value={formatDashboardCreditMajor(overview.allocated_credit, displayCurrency)}
|
||||
/>
|
||||
<AgentMetric
|
||||
<DashboardScopeMetric
|
||||
label={t("agent.creditUsedLabel")}
|
||||
value={formatDashboardCreditMajor(overview.used_credit, displayCurrency)}
|
||||
/>
|
||||
<AgentMetric
|
||||
label={t("agent.pendingBills")}
|
||||
value={String(overview.pending_bill_count)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("agent.lineMeta", {
|
||||
@@ -231,24 +241,21 @@ export function AgentDashboardConsole(): ReactElement {
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold">{t("agent.sevenDayTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground">{t("agent.todayBet")}</span>
|
||||
<span className="font-semibold tabular-nums">
|
||||
{formatDashboardMoneyMinor(overview.seven_day_bet_minor, displayCurrency)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground">{t("agent.todayShareProfit")}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"font-semibold tabular-nums",
|
||||
signedMoneyClass(overview.seven_day_profit_minor, true),
|
||||
)}
|
||||
>
|
||||
{formatDashboardSignedMoneyMinor(overview.seven_day_profit_minor, displayCurrency)}
|
||||
</span>
|
||||
</div>
|
||||
<CardContent className="space-y-2">
|
||||
<DashboardStatRow
|
||||
label={t("agent.sevenDayBet")}
|
||||
value={formatDashboardMoneyMinor(overview.seven_day_bet_minor, displayCurrency)}
|
||||
/>
|
||||
<DashboardStatRow
|
||||
label={t("agent.sevenDayPayoutLabel")}
|
||||
value={formatDashboardMoneyMinor(overview.seven_day_payout_minor, displayCurrency)}
|
||||
/>
|
||||
<DashboardSignedStatRow
|
||||
label={t("agent.sevenDayShareProfitLabel")}
|
||||
amountMinor={overview.seven_day_profit_minor}
|
||||
currencyCode={displayCurrency}
|
||||
/>
|
||||
<p className="pt-1 text-xs text-muted-foreground">{t("agent.shareProfitScopeHint")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -257,19 +264,19 @@ export function AgentDashboardConsole(): ReactElement {
|
||||
<CardTitle className="text-sm font-semibold">{t("agent.teamTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-2 gap-3 text-sm">
|
||||
<AgentMetric
|
||||
<DashboardScopeMetric
|
||||
label={t("agent.directChildren")}
|
||||
value={String(overview.direct_child_count)}
|
||||
/>
|
||||
<AgentMetric
|
||||
<DashboardScopeMetric
|
||||
label={t("agent.directPlayers")}
|
||||
value={String(overview.direct_player_count)}
|
||||
/>
|
||||
<AgentMetric
|
||||
<DashboardScopeMetric
|
||||
label={t("agent.subtreeAgents")}
|
||||
value={String(overview.subtree_agent_count)}
|
||||
/>
|
||||
<AgentMetric
|
||||
<DashboardScopeMetric
|
||||
label={t("agent.teamPlayers")}
|
||||
value={String(overview.team_player_count)}
|
||||
/>
|
||||
@@ -277,7 +284,11 @@ export function AgentDashboardConsole(): ReactElement {
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
) : (
|
||||
<AdminNoResourceState className="py-12 text-sm text-muted-foreground">
|
||||
{t("agent.overviewEmpty")}
|
||||
</AdminNoResourceState>
|
||||
)}
|
||||
|
||||
<DashboardCurrentDrawCard
|
||||
key={`${hall?.draw_no ?? "empty"}:${loading ? "loading" : "ready"}`}
|
||||
@@ -286,18 +297,15 @@ export function AgentDashboardConsole(): ReactElement {
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
{canFinance ? (
|
||||
{canAnalytics ? (
|
||||
<DashboardAnalyticsPanel
|
||||
enabled={canFinance}
|
||||
enabled={canAnalytics}
|
||||
playOptions={playOptions}
|
||||
scope={analyticsScope}
|
||||
/>
|
||||
) : (
|
||||
<Alert className="border-muted">
|
||||
<AlertTitle>{t("notice")}</AlertTitle>
|
||||
<AlertDescription>{t("warnings.drawPermission")}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
) : !loading ? (
|
||||
<p className="text-xs text-muted-foreground">{t("warnings.analyticsUnavailable")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { getAdminRequestLocale } from "@/lib/admin-locale";
|
||||
import { signedMoneyClass } from "@/lib/admin-signed-money";
|
||||
import { signedMoneyClass, signedTrendClassName } from "@/lib/admin-signed-money";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DashboardKpiCard } from "@/modules/dashboard/dashboard-visuals";
|
||||
import { DASHBOARD_CHART_COLORS } from "@/modules/dashboard/dashboard-chart-config";
|
||||
@@ -56,16 +56,8 @@ function computeDeltaPercent(series: number[]): string | null {
|
||||
return `${sign} ${Math.abs(pct).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function deltaClassName(series: number[]): string {
|
||||
if (series.length < 2) {
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
const last = series[series.length - 1];
|
||||
const prev = series[series.length - 2];
|
||||
if (last >= prev) {
|
||||
return "text-emerald-600 dark:text-emerald-400";
|
||||
}
|
||||
return "text-destructive";
|
||||
function deltaClassName(series: number[], mode: "higherBetter" | "lowerBetter" | "signed"): string {
|
||||
return signedTrendClassName(series, mode);
|
||||
}
|
||||
|
||||
export function DashboardAnalyticsMain({ analytics }: { analytics: DashboardAnalyticsState }): ReactNode {
|
||||
@@ -190,13 +182,13 @@ export function DashboardAnalyticsMain({ analytics }: { analytics: DashboardAnal
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<div className="grid min-w-0 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<div className="grid min-w-0 gap-3 sm:grid-cols-2 2xl:grid-cols-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-28 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : summary ? (
|
||||
<div className="grid min-w-0 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<div className="grid min-w-0 gap-3 sm:grid-cols-2 2xl:grid-cols-3">
|
||||
<DashboardKpiCard
|
||||
label={t("analytics.summaryBet")}
|
||||
value={formatMoney(summary.total_bet_minor, currency)}
|
||||
@@ -208,7 +200,7 @@ export function DashboardAnalyticsMain({ analytics }: { analytics: DashboardAnal
|
||||
sparklineValues={sparklines.bet}
|
||||
deltaLabel={
|
||||
computeDeltaPercent(sparklines.bet) ? (
|
||||
<span className={deltaClassName(sparklines.bet)}>
|
||||
<span className={deltaClassName(sparklines.bet, "higherBetter")}>
|
||||
{computeDeltaPercent(sparklines.bet)}
|
||||
</span>
|
||||
) : undefined
|
||||
@@ -229,7 +221,7 @@ export function DashboardAnalyticsMain({ analytics }: { analytics: DashboardAnal
|
||||
sparklineValues={sparklines.payout}
|
||||
deltaLabel={
|
||||
computeDeltaPercent(sparklines.payout) ? (
|
||||
<span className={deltaClassName(sparklines.payout)}>
|
||||
<span className={deltaClassName(sparklines.payout, "lowerBetter")}>
|
||||
{computeDeltaPercent(sparklines.payout)}
|
||||
</span>
|
||||
) : undefined
|
||||
@@ -241,8 +233,8 @@ export function DashboardAnalyticsMain({ analytics }: { analytics: DashboardAnal
|
||||
? t("analytics.summaryShareProfit")
|
||||
: t("analytics.summaryProfit")
|
||||
}
|
||||
value={formatSignedMoney(summary.approx_house_gross_minor, currency)}
|
||||
valueClassName={signedMoneyClass(summary.approx_house_gross_minor, true)}
|
||||
signedAmountMinor={summary.approx_house_gross_minor}
|
||||
currencyCode={currency}
|
||||
hint={
|
||||
profitScope === "share_profit"
|
||||
? t("analytics.shareProfitHint")
|
||||
@@ -256,7 +248,7 @@ export function DashboardAnalyticsMain({ analytics }: { analytics: DashboardAnal
|
||||
sparklineValues={sparklines.profit}
|
||||
deltaLabel={
|
||||
computeDeltaPercent(sparklines.profit) ? (
|
||||
<span className={deltaClassName(sparklines.profit)}>
|
||||
<span className={deltaClassName(sparklines.profit, "signed")}>
|
||||
{computeDeltaPercent(sparklines.profit)}
|
||||
</span>
|
||||
) : undefined
|
||||
|
||||
@@ -4,7 +4,7 @@ import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertTriangle, ClipboardList, RefreshCw, Shield, Wallet } from "lucide-react";
|
||||
import { AlertTriangle, BarChart3, ClipboardList, RefreshCw, Shield, TrendingUp, Wallet } from "lucide-react";
|
||||
|
||||
import { getAdminDashboardByScope } from "@/api/admin-dashboard";
|
||||
import { useAdminPlayTypeCatalog } from "@/hooks/use-admin-play-type-catalog";
|
||||
@@ -18,6 +18,9 @@ import {
|
||||
} from "@/modules/dashboard/dashboard-analytics-panel";
|
||||
import { DashboardCurrentDrawCard } from "@/modules/dashboard/dashboard-current-draw-card";
|
||||
import { useDashboardAnalytics } from "@/modules/dashboard/use-dashboard-analytics";
|
||||
import {
|
||||
formatDashboardMoneyMinor,
|
||||
} from "@/modules/dashboard/use-dashboard-analytics";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
@@ -39,12 +42,18 @@ import type {
|
||||
AdminDashboardLifetimeFinance,
|
||||
AdminDashboardPlatformRisk,
|
||||
AdminDashboardResultBatchQueue,
|
||||
AdminDashboardTodayFinance,
|
||||
AdminDashboardWarning,
|
||||
} 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";
|
||||
|
||||
// recharts 图表组件懒加载,避免 ~200KB 进入主 bundle
|
||||
const DashboardKpiCard = dynamic(
|
||||
() => import("@/modules/dashboard/dashboard-visuals").then((m) => ({ default: m.DashboardKpiCard })),
|
||||
{ ssr: false },
|
||||
);
|
||||
const DashboardPanelCard = dynamic(
|
||||
() => import("@/modules/dashboard/dashboard-visuals").then((m) => ({ default: m.DashboardPanelCard })),
|
||||
{ ssr: false },
|
||||
@@ -172,6 +181,8 @@ export function DashboardConsole(): ReactElement {
|
||||
const [lifetimeFinance, setLifetimeFinance] = useState<AdminDashboardLifetimeFinance | null>(
|
||||
null,
|
||||
);
|
||||
const [todayFinance, setTodayFinance] = useState<AdminDashboardTodayFinance | null>(null);
|
||||
const [apiWarnings, setApiWarnings] = useState<AdminDashboardWarning[]>([]);
|
||||
const [platformRisk, setPlatformRisk] = useState<AdminDashboardPlatformRisk | null>(null);
|
||||
const [riskLocked, setRiskLocked] = useState(0);
|
||||
const [riskCap, setRiskCap] = useState(0);
|
||||
@@ -193,6 +204,8 @@ export function DashboardConsole(): ReactElement {
|
||||
setDrawPanel(null);
|
||||
setResultBatchQueue(null);
|
||||
setLifetimeFinance(null);
|
||||
setTodayFinance(null);
|
||||
setApiWarnings([]);
|
||||
setPlatformRisk(null);
|
||||
setDrawId(null);
|
||||
setRiskLocked(0);
|
||||
@@ -214,6 +227,8 @@ export function DashboardConsole(): ReactElement {
|
||||
}
|
||||
setResultBatchQueue(d.result_batch_queue);
|
||||
setLifetimeFinance(d.lifetime_finance);
|
||||
setTodayFinance(d.today_finance);
|
||||
setApiWarnings(d.warnings ?? []);
|
||||
setPlatformRisk(d.platform_risk);
|
||||
if (d.draw != null) {
|
||||
setDrawPanel(d.draw);
|
||||
@@ -239,7 +254,10 @@ export function DashboardConsole(): ReactElement {
|
||||
}, []);
|
||||
|
||||
const currency =
|
||||
lifetimeFinance?.currency_code ?? finance?.currency_code ?? null;
|
||||
todayFinance?.currency_code
|
||||
?? lifetimeFinance?.currency_code
|
||||
?? finance?.currency_code
|
||||
?? null;
|
||||
const canFinance = capabilities?.draw_finance_risk ?? false;
|
||||
const platformLocked = coerceAdminMinor(platformRisk?.locked_amount);
|
||||
const platformCap = coerceAdminMinor(platformRisk?.cap_amount);
|
||||
@@ -296,6 +314,13 @@ export function DashboardConsole(): ReactElement {
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{!loading && apiWarnings.length > 0 ? (
|
||||
<Alert className="border-amber-200 bg-amber-50 dark:border-amber-900/60 dark:bg-amber-950/30">
|
||||
<AlertTitle>{t("notice")}</AlertTitle>
|
||||
<AlertDescription>{apiWarnings.map((w) => w.message).join(" ")}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<section className="flex min-w-0 flex-col gap-4">
|
||||
<DashboardCurrentDrawCard
|
||||
key={`${hall?.draw_no ?? "empty"}:${hall?.seconds_to_close ?? 0}:${loading ? "loading" : "ready"}`}
|
||||
@@ -303,6 +328,42 @@ export function DashboardConsole(): ReactElement {
|
||||
drawId={drawId}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
{canFinance && !loading && todayFinance ? (
|
||||
<div className="grid min-w-0 grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<DashboardKpiCard
|
||||
label={t("todayBetTotal")}
|
||||
value={formatDashboardMoneyMinor(todayFinance.total_bet_minor, currency)}
|
||||
icon={<TrendingUp className="size-4" />}
|
||||
hint={t("todayBusinessDateHint", { date: todayFinance.business_date })}
|
||||
/>
|
||||
<DashboardKpiCard
|
||||
label={t("todayProfit")}
|
||||
signedAmountMinor={todayFinance.approx_house_gross_minor}
|
||||
currencyCode={currency}
|
||||
icon={<BarChart3 className="size-4" />}
|
||||
hint={t("todayPayoutHint", {
|
||||
amount: formatDashboardMoneyMinor(todayFinance.total_payout_minor, currency),
|
||||
})}
|
||||
/>
|
||||
<DashboardKpiCard
|
||||
label={t("lifetimeProfit")}
|
||||
signedAmountMinor={lifetimeFinance?.approx_house_gross_minor}
|
||||
currencyCode={currency}
|
||||
icon={<Wallet className="size-4" />}
|
||||
hint={
|
||||
lifetimeFinance
|
||||
? t("lifetimeActivityHint", {
|
||||
draws: lifetimeFinance.draw_count,
|
||||
days: lifetimeFinance.business_day_count,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
value={lifetimeFinance ? undefined : "—"}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid min-w-0 grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<DashboardPanelCard
|
||||
href={pendingReviewHref(drawId, resultBatchQueue)}
|
||||
@@ -337,7 +398,7 @@ export function DashboardConsole(): ReactElement {
|
||||
>
|
||||
<AbnormalTransferPanelFooter
|
||||
total={abnormalTransferTotal}
|
||||
walletPermission={capabilities?.wallet_transfer_view ?? true}
|
||||
walletPermission={capabilities?.wallet_transfer_view ?? false}
|
||||
/>
|
||||
</DashboardPanelCard>
|
||||
|
||||
@@ -345,16 +406,6 @@ export function DashboardConsole(): ReactElement {
|
||||
href="/admin/risk"
|
||||
title={t("riskCapUsage")}
|
||||
value={`${platformUsagePct.toFixed(1)}%`}
|
||||
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={
|
||||
@@ -380,7 +431,7 @@ export function DashboardConsole(): ReactElement {
|
||||
|
||||
<DashboardPanelCard
|
||||
href="/admin/reports"
|
||||
title={t("payoutComposition")}
|
||||
title={t("lifetimePayout")}
|
||||
value={
|
||||
lifetimeFinance
|
||||
? formatMoneyMinor(lifetimeFinance.total_payout_minor, currency)
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
getAdminCurrencyDecimalPlaces,
|
||||
} from "@/lib/money";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SignedMoney, signedMoneyClass } from "@/lib/admin-signed-money";
|
||||
import {
|
||||
buildBatchProgressConfig,
|
||||
buildFinanceStructureConfig,
|
||||
@@ -46,6 +47,7 @@ import {
|
||||
DASHBOARD_CHART_COLORS,
|
||||
} from "@/modules/dashboard/dashboard-chart-config";
|
||||
import { DashboardChartEmpty } from "@/modules/dashboard/dashboard-chart-empty";
|
||||
import { formatDashboardSignedMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
|
||||
import type { AdminDrawFinanceSummaryData } from "@/types/api/admin-draw-finance";
|
||||
import type { AdminDashboardLifetimeFinance } from "@/types/api/admin-dashboard";
|
||||
import type { AdminRiskPoolRow } from "@/types/api/admin-risk";
|
||||
@@ -179,6 +181,73 @@ function kpiAccentClass(accent: DashboardKpiAccent): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** 站点/代理卡片内 label | amount 行,金额过长时可换行 */
|
||||
export function DashboardStatRow({
|
||||
label,
|
||||
value,
|
||||
valueClassName,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
valueClassName?: string;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-3 text-sm">
|
||||
<span className="shrink-0 text-muted-foreground">{label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 break-all text-right font-semibold tabular-nums leading-tight",
|
||||
valueClassName ?? "text-foreground",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 盈亏行:正绿、负红、零灰(与 {@link signedMoneyClass} 一致) */
|
||||
export function DashboardSignedStatRow({
|
||||
label,
|
||||
amountMinor,
|
||||
currencyCode,
|
||||
}: {
|
||||
label: string;
|
||||
amountMinor: number;
|
||||
currencyCode: string | null;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-3 text-sm">
|
||||
<span className="shrink-0 text-muted-foreground">{label}</span>
|
||||
<SignedMoney
|
||||
amount={amountMinor}
|
||||
emphasize
|
||||
className="min-w-0 break-all text-right leading-tight"
|
||||
>
|
||||
{formatDashboardSignedMoneyMinor(amountMinor, currencyCode)}
|
||||
</SignedMoney>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 规模/授信等栅格指标,金额可换行 */
|
||||
export function DashboardScopeMetric({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<div className="rounded-lg border bg-muted/30 px-3 py-2.5">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className="mt-1 break-all text-base font-semibold tabular-nums leading-tight text-foreground">
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 财务概览区紧凑 KPI,避免 StatCard 在窄栅格内撑破布局 */
|
||||
export function DashboardKpiCard({
|
||||
label,
|
||||
@@ -187,43 +256,60 @@ export function DashboardKpiCard({
|
||||
icon,
|
||||
accent = "primary",
|
||||
valueClassName,
|
||||
signedAmountMinor,
|
||||
currencyCode,
|
||||
sparklineValues,
|
||||
deltaLabel,
|
||||
}: {
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
value?: ReactNode;
|
||||
hint?: ReactNode;
|
||||
icon: ReactNode;
|
||||
accent?: DashboardKpiAccent;
|
||||
/** 覆盖主数值颜色(如盈亏红绿) */
|
||||
valueClassName?: string;
|
||||
/** 盈亏类 KPI:自动带 +/- 与红绿 */
|
||||
signedAmountMinor?: number;
|
||||
currencyCode?: string | null;
|
||||
sparklineValues?: number[];
|
||||
deltaLabel?: ReactNode;
|
||||
}): ReactElement {
|
||||
const resolvedValue =
|
||||
typeof signedAmountMinor === "number" && currencyCode !== undefined
|
||||
? formatDashboardSignedMoneyMinor(signedAmountMinor, currencyCode)
|
||||
: value;
|
||||
const resolvedValueClassName =
|
||||
typeof signedAmountMinor === "number"
|
||||
? signedMoneyClass(signedAmountMinor, true)
|
||||
: valueClassName;
|
||||
const valueTitle =
|
||||
typeof resolvedValue === "string" || typeof resolvedValue === "number"
|
||||
? String(resolvedValue)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-w-0 flex-col rounded-xl border border-border/60 bg-card p-4">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="min-w-0 flex-1 text-xs font-medium leading-snug text-muted-foreground">{label}</p>
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-10 shrink-0 items-center justify-center rounded-lg",
|
||||
"flex size-9 shrink-0 items-center justify-center rounded-lg [&_svg]:size-4",
|
||||
kpiAccentClass(accent),
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium text-muted-foreground">{label}</p>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-1 truncate text-xl font-bold tabular-nums tracking-tight",
|
||||
valueClassName ?? "text-foreground",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
{deltaLabel ? <div className="mt-1 text-xs font-medium tabular-nums">{deltaLabel}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
title={valueTitle}
|
||||
className={cn(
|
||||
"mt-2 break-words text-base font-bold tabular-nums leading-tight tracking-tight sm:text-lg",
|
||||
resolvedValueClassName ?? "text-foreground",
|
||||
)}
|
||||
>
|
||||
{resolvedValue}
|
||||
</p>
|
||||
{deltaLabel ? <div className="mt-1 text-xs font-medium tabular-nums">{deltaLabel}</div> : null}
|
||||
{sparklineValues && sparklineValues.length >= 2 ? (
|
||||
<div className="mt-3 flex justify-end">
|
||||
<MiniSparkline
|
||||
@@ -239,7 +325,7 @@ export function DashboardKpiCard({
|
||||
</div>
|
||||
) : null}
|
||||
{hint ? (
|
||||
<p className="mt-2 line-clamp-2 text-[11px] leading-snug text-muted-foreground">{hint}</p>
|
||||
<p className="mt-2 text-[11px] leading-snug text-muted-foreground">{hint}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
@@ -477,9 +563,7 @@ export function DashboardPanelCard({
|
||||
</p>
|
||||
)}
|
||||
{subtitle && !loading ? (
|
||||
<p className="mt-2 line-clamp-2 text-xs leading-relaxed text-muted-foreground">
|
||||
{subtitle}
|
||||
</p>
|
||||
<div className="mt-2 text-xs leading-snug text-muted-foreground">{subtitle}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -621,19 +705,48 @@ export function CapUsageBar({
|
||||
const radialData = useMemo(() => [{ usage: pct, fill }], [pct, fill]);
|
||||
|
||||
if (compact) {
|
||||
const lockedLabel = formatMoney(locked, currency);
|
||||
const capLabel = cap > 0 ? formatMoney(cap, currency) : t("platformCapUnset");
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-2 overflow-hidden rounded-full bg-muted"
|
||||
role="progressbar"
|
||||
aria-valuenow={pct}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label={t("riskCapUsage")}
|
||||
>
|
||||
<div className="space-y-2.5">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="rounded-lg bg-primary/5 px-2.5 py-2 ring-1 ring-primary/15">
|
||||
<p className="text-[10px] font-medium uppercase tracking-wide text-primary/80">
|
||||
{t("platformLockedLabel")}
|
||||
</p>
|
||||
<p
|
||||
className="mt-1 break-all text-sm font-semibold tabular-nums leading-tight text-foreground"
|
||||
title={lockedLabel}
|
||||
>
|
||||
{lockedLabel}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted/50 px-2.5 py-2 ring-1 ring-border/60">
|
||||
<p className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{t("platformCapLabel")}
|
||||
</p>
|
||||
<p
|
||||
className="mt-1 break-all text-sm font-semibold tabular-nums leading-tight text-foreground"
|
||||
title={capLabel}
|
||||
>
|
||||
{capLabel}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="h-full rounded-full transition-[width] duration-500"
|
||||
style={{ width: `${pct}%`, backgroundColor: fill }}
|
||||
/>
|
||||
className="h-2 overflow-hidden rounded-full bg-muted"
|
||||
role="progressbar"
|
||||
aria-valuenow={pct}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label={t("riskCapUsage")}
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full transition-[width] duration-500"
|
||||
style={{ width: `${pct}%`, backgroundColor: fill }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -741,6 +854,12 @@ export function FinanceStructureChart({
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
{t("payoutRateOfBet", { rate: payoutRate })}
|
||||
</p>
|
||||
<p className="text-center text-sm tabular-nums">
|
||||
<span className="text-muted-foreground">{t("houseGross")} </span>
|
||||
<SignedMoney amount={gross} emphasize>
|
||||
{formatDashboardSignedMoneyMinor(gross, currency)}
|
||||
</SignedMoney>
|
||||
</p>
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -10,40 +10,43 @@ import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { useCachedPlayTypeOptions } from "@/hooks/use-cached-play-type-options";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { PRD_REPORTS_VIEW_ACCESS_ANY } from "@/lib/admin-prd";
|
||||
import { PRD_DASHBOARD_ANALYTICS_ACCESS_ANY } from "@/lib/admin-prd";
|
||||
import { normalizeAdminLanguage } from "@/i18n";
|
||||
import { adminWeekdayKeyForDate, formatAdminCalendarToday } from "@/lib/admin-datetime";
|
||||
import { signedMoneyClass } from "@/lib/admin-signed-money";
|
||||
import { adminWeekdayKeyForDate, formatAdminBusinessDateIso, formatAdminCalendarToday } from "@/lib/admin-datetime";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { DashboardCurrentDrawCard } from "@/modules/dashboard/dashboard-current-draw-card";
|
||||
import { DashboardAnalyticsPanel } from "@/modules/dashboard/dashboard-analytics-panel";
|
||||
import { DashboardKpiCard } from "@/modules/dashboard/dashboard-visuals";
|
||||
import {
|
||||
DashboardKpiCard,
|
||||
DashboardScopeMetric,
|
||||
DashboardSignedStatRow,
|
||||
DashboardStatRow,
|
||||
} from "@/modules/dashboard/dashboard-visuals";
|
||||
import {
|
||||
formatDashboardMoneyMinor,
|
||||
formatDashboardSignedMoneyMinor,
|
||||
} from "@/modules/dashboard/use-dashboard-analytics";
|
||||
import type { AdminDashboardSiteOverview } from "@/types/api/admin-dashboard";
|
||||
import type { AdminDashboardSiteOverview, AdminDashboardWarning } from "@/types/api/admin-dashboard";
|
||||
import type { DrawCurrentSnapshot } from "@/types/api/public-draw";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
|
||||
function SiteMetric({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<div className="rounded-lg border bg-muted/30 px-3 py-2.5">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className="mt-1 text-base font-semibold tabular-nums text-foreground">{value}</p>
|
||||
</div>
|
||||
);
|
||||
function buildTodayBetHint(
|
||||
businessDate: string,
|
||||
latestBetAt: string | null,
|
||||
t: (key: string, opts?: Record<string, unknown>) => string,
|
||||
formatDt: (iso: string) => string,
|
||||
): string {
|
||||
const dateHint = t("todayBusinessDateHint", { date: businessDate });
|
||||
if (latestBetAt) {
|
||||
return `${dateHint} · ${t("site.latestBetAt", { time: formatDt(latestBetAt) })}`;
|
||||
}
|
||||
|
||||
return `${dateHint} · ${t("site.noBetToday")}`;
|
||||
}
|
||||
|
||||
export function SiteDashboardConsole(): ReactElement {
|
||||
@@ -53,6 +56,7 @@ export function SiteDashboardConsole(): ReactElement {
|
||||
const profile = useAdminProfile();
|
||||
const site = profile?.site ?? null;
|
||||
const permissions = useMemo(() => profile?.permissions ?? [], [profile?.permissions]);
|
||||
const businessDateToday = useMemo(() => formatAdminBusinessDateIso(), []);
|
||||
|
||||
const todayLabel = useMemo(() => {
|
||||
const locale = normalizeAdminLanguage(i18n.resolvedLanguage ?? i18n.language);
|
||||
@@ -66,6 +70,7 @@ export function SiteDashboardConsole(): ReactElement {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [apiWarnings, setApiWarnings] = useState<AdminDashboardWarning[]>([]);
|
||||
const [hall, setHall] = useState<DrawCurrentSnapshot | null>(null);
|
||||
const [drawId, setDrawId] = useState<number | null>(null);
|
||||
const [overview, setOverview] = useState<AdminDashboardSiteOverview | null>(null);
|
||||
@@ -78,7 +83,7 @@ export function SiteDashboardConsole(): ReactElement {
|
||||
[overview?.site_code, site?.code],
|
||||
);
|
||||
|
||||
const canAnalytics = adminHasAnyPermission(permissions, [...PRD_REPORTS_VIEW_ACCESS_ANY]);
|
||||
const canAnalytics = adminHasAnyPermission(permissions, [...PRD_DASHBOARD_ANALYTICS_ACCESS_ANY]);
|
||||
|
||||
const load = useCallback(async (isRefresh = false) => {
|
||||
if (isRefresh) {
|
||||
@@ -92,6 +97,7 @@ export function SiteDashboardConsole(): ReactElement {
|
||||
const d = await getAdminDashboard();
|
||||
setHall(d.hall);
|
||||
setOverview(d.site_overview);
|
||||
setApiWarnings(d.warnings ?? []);
|
||||
if (d.resolved_draw != null) {
|
||||
setDrawId(d.resolved_draw.id);
|
||||
} else {
|
||||
@@ -144,31 +150,36 @@ export function SiteDashboardConsole(): ReactElement {
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{!loading && apiWarnings.length > 0 ? (
|
||||
<Alert className="border-amber-200 bg-amber-50 dark:border-amber-900/60 dark:bg-amber-950/30">
|
||||
<AlertTitle>{t("notice")}</AlertTitle>
|
||||
<AlertDescription>{apiWarnings.map((w) => w.message).join(" ")}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-24 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : overview ? (
|
||||
<section className="space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<DashboardKpiCard
|
||||
label={t("site.todayBet")}
|
||||
value={formatDashboardMoneyMinor(overview.today_bet_minor, displayCurrency)}
|
||||
icon={<TrendingUp className="size-4" />}
|
||||
hint={
|
||||
overview.latest_bet_at
|
||||
? t("site.latestBetAt", { time: formatDt(overview.latest_bet_at) })
|
||||
: t("site.noBetToday")
|
||||
}
|
||||
hint={buildTodayBetHint(businessDateToday, overview.latest_bet_at, t, formatDt)}
|
||||
/>
|
||||
<DashboardKpiCard
|
||||
label={t("site.todayProfit")}
|
||||
value={formatDashboardSignedMoneyMinor(overview.today_profit_minor, displayCurrency)}
|
||||
signedAmountMinor={overview.today_profit_minor}
|
||||
currencyCode={displayCurrency}
|
||||
icon={<BarChart3 className="size-4" />}
|
||||
hint={t("site.profitScopeHint")}
|
||||
valueClassName={signedMoneyClass(overview.today_profit_minor, true)}
|
||||
hint={t("todayPayoutHint", {
|
||||
amount: formatDashboardMoneyMinor(overview.today_payout_minor, displayCurrency),
|
||||
})}
|
||||
/>
|
||||
<DashboardKpiCard
|
||||
label={t("site.activePlayersToday")}
|
||||
@@ -192,24 +203,21 @@ export function SiteDashboardConsole(): ReactElement {
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold">{t("site.sevenDayTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground">{t("site.todayBet")}</span>
|
||||
<span className="font-semibold tabular-nums">
|
||||
{formatDashboardMoneyMinor(overview.seven_day_bet_minor, displayCurrency)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground">{t("site.sevenDayProfit")}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"font-semibold tabular-nums",
|
||||
signedMoneyClass(overview.seven_day_profit_minor, true),
|
||||
)}
|
||||
>
|
||||
{formatDashboardSignedMoneyMinor(overview.seven_day_profit_minor, displayCurrency)}
|
||||
</span>
|
||||
</div>
|
||||
<CardContent className="space-y-2">
|
||||
<DashboardStatRow
|
||||
label={t("site.sevenDayBet")}
|
||||
value={formatDashboardMoneyMinor(overview.seven_day_bet_minor, displayCurrency)}
|
||||
/>
|
||||
<DashboardStatRow
|
||||
label={t("site.sevenDayPayout")}
|
||||
value={formatDashboardMoneyMinor(overview.seven_day_payout_minor, displayCurrency)}
|
||||
/>
|
||||
<DashboardSignedStatRow
|
||||
label={t("site.sevenDayProfit")}
|
||||
amountMinor={overview.seven_day_profit_minor}
|
||||
currencyCode={displayCurrency}
|
||||
/>
|
||||
<p className="pt-1 text-xs text-muted-foreground">{t("site.profitScopeHint")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -218,24 +226,31 @@ export function SiteDashboardConsole(): ReactElement {
|
||||
<CardTitle className="text-sm font-semibold">{t("site.scaleTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-2 gap-3 text-sm">
|
||||
<SiteMetric label={t("site.agentCount")} value={String(overview.agent_count)} />
|
||||
<SiteMetric label={t("site.playerCount")} value={String(overview.player_count)} />
|
||||
<DashboardScopeMetric label={t("site.agentCount")} value={String(overview.agent_count)} />
|
||||
<DashboardScopeMetric label={t("site.playerCount")} value={String(overview.player_count)} />
|
||||
{overview.top_agent_today ? (
|
||||
<div className="col-span-2 rounded-lg border bg-muted/20 px-3 py-2.5 text-xs text-muted-foreground">
|
||||
{t("site.topAgentToday", {
|
||||
name: overview.top_agent_today.agent_name || overview.top_agent_today.agent_code,
|
||||
amount: formatDashboardMoneyMinor(
|
||||
<div className="col-span-2 rounded-lg border bg-muted/20 px-3 py-2.5">
|
||||
<p className="text-xs text-muted-foreground">{t("site.topAgentTodayLabel")}</p>
|
||||
<p className="mt-1 font-medium text-foreground">
|
||||
{overview.top_agent_today.agent_name || overview.top_agent_today.agent_code}
|
||||
</p>
|
||||
<p className="mt-0.5 break-all text-sm font-semibold tabular-nums">
|
||||
{formatDashboardMoneyMinor(
|
||||
overview.top_agent_today.total_bet_minor,
|
||||
displayCurrency,
|
||||
),
|
||||
})}
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
) : (
|
||||
<AdminNoResourceState className="py-12 text-sm text-muted-foreground">
|
||||
{t("site.overviewEmpty")}
|
||||
</AdminNoResourceState>
|
||||
)}
|
||||
|
||||
<DashboardCurrentDrawCard
|
||||
key={`${hall?.draw_no ?? "empty"}:${loading ? "loading" : "ready"}`}
|
||||
|
||||
@@ -35,7 +35,6 @@ import {
|
||||
} from "@/api/admin-reports";
|
||||
import {
|
||||
buildReportJobParameters,
|
||||
REPORT_UI_SERVER_FULL_EXPORT,
|
||||
REPORT_UI_TO_JOB_TYPE,
|
||||
type ReportUiKey,
|
||||
} from "@/lib/report-export-map";
|
||||
@@ -239,37 +238,6 @@ function resolveDisplayCurrency(apiCode?: string | null): string {
|
||||
return fallback?.trim() || "NPR";
|
||||
}
|
||||
|
||||
function reportTimeAxisKey(key: ReportKey): "businessDate" | "recordCreatedAt" | null {
|
||||
switch (key) {
|
||||
case "daily_profit":
|
||||
case "player_win_loss":
|
||||
case "play_dimension":
|
||||
case "rebate_commission":
|
||||
return "businessDate";
|
||||
case "player_transfer":
|
||||
case "admin_audit":
|
||||
return "recordCreatedAt";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function reportDisclaimerKey(key: ReportKey): string | null {
|
||||
switch (key) {
|
||||
case "draw_profit":
|
||||
case "daily_profit":
|
||||
case "player_win_loss":
|
||||
case "play_dimension":
|
||||
return "items.profit_reports.disclaimer";
|
||||
case "player_transfer":
|
||||
return "items.player_transfer.disclaimer";
|
||||
case "rebate_commission":
|
||||
return "items.rebate_commission.disclaimer";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const emptySearch: SearchState = {
|
||||
open: null,
|
||||
query: "",
|
||||
@@ -328,41 +296,6 @@ function formatExportInstant(iso: string | null | undefined): ExportCell {
|
||||
return formatAdminInstant(iso, { locale: getAdminRequestLocale() });
|
||||
}
|
||||
|
||||
function toCsvValue(value: ExportCell): string {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const stringValue = String(value);
|
||||
if (/[",\n]/.test(stringValue)) {
|
||||
return `"${stringValue.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return stringValue;
|
||||
}
|
||||
|
||||
async function exportRows(rows: ExportRow[], filename: string, sheetName: string, format: ExportFormat): Promise<void> {
|
||||
if (rows.length === 0) {
|
||||
throw new LotteryApiBizError("no_data", -1, null);
|
||||
}
|
||||
|
||||
if (format === "csv") {
|
||||
const headers = Object.keys(rows[0]);
|
||||
const lines = [
|
||||
headers.map(toCsvValue).join(","),
|
||||
...rows.map((row) => headers.map((header) => toCsvValue(row[header] ?? "")).join(",")),
|
||||
];
|
||||
const blob = new Blob([`\uFEFF${lines.join("\n")}`], { type: "text/csv;charset=utf-8;" });
|
||||
downloadBlob(blob, `${filename}.csv`);
|
||||
return;
|
||||
}
|
||||
|
||||
const XLSX = await import("xlsx");
|
||||
const worksheet = XLSX.utils.json_to_sheet(rows);
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
|
||||
XLSX.writeFile(workbook, `${filename}.xlsx`);
|
||||
}
|
||||
|
||||
function buildDailyProfitRowsAndSummary(
|
||||
items: AdminReportDailyProfitRow[],
|
||||
total: number,
|
||||
@@ -742,7 +675,7 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
}, [filteredReports, selectedKey]);
|
||||
|
||||
const pageScopedLabel = useCallback(
|
||||
(statKey: string) => `${t(`preview.stats.${statKey}`)} · ${t("preview.scope.currentPage")}`,
|
||||
(statKey: string) => t(`preview.stats.${statKey}`),
|
||||
[t],
|
||||
);
|
||||
|
||||
@@ -1233,9 +1166,7 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
const usesServerExport = REPORT_UI_SERVER_FULL_EXPORT.has(selectedReport.key as ReportUiKey);
|
||||
|
||||
async function exportViaServer(format: ExportFormat): Promise<void> {
|
||||
async function exportReport(format: ExportFormat): Promise<void> {
|
||||
if (!canExportReports) {
|
||||
return;
|
||||
}
|
||||
@@ -1260,10 +1191,9 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
const ext = job.export_format === "xlsx" ? "xlsx" : "csv";
|
||||
downloadBlob(blob, filename ?? `${exportFileBase}.${ext}`);
|
||||
toast.success(
|
||||
t("exportServerSuccess", {
|
||||
t("exportSuccess", {
|
||||
report: t(`items.${selectedReport.key}.title`),
|
||||
format: t(`formats.${format}`),
|
||||
jobNo: job.job_no,
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
@@ -1273,36 +1203,6 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
}
|
||||
}
|
||||
|
||||
function exportPreview(format: ExportFormat): void {
|
||||
if (!canExportReports) {
|
||||
return;
|
||||
}
|
||||
if (!result || result.rows.length === 0) {
|
||||
toast.info(t("empty"));
|
||||
return;
|
||||
}
|
||||
setExporting(format);
|
||||
try {
|
||||
exportRows(result.rows, exportFileBase, t(`items.${selectedReport.key}.title`), format);
|
||||
toast.success(t("exportSuccess", { report: t(`items.${selectedReport.key}.title`), format: t(`formats.${format}`) }));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof LotteryApiBizError ? err.message : t("exportFailed"));
|
||||
} finally {
|
||||
setExporting(null);
|
||||
}
|
||||
}
|
||||
|
||||
function exportReport(format: ExportFormat): void {
|
||||
if (!canExportReports) {
|
||||
return;
|
||||
}
|
||||
if (usesServerExport) {
|
||||
void exportViaServer(format);
|
||||
return;
|
||||
}
|
||||
exportPreview(format);
|
||||
}
|
||||
|
||||
const renderSearchPicker = (kind: SearchKind) => {
|
||||
const value =
|
||||
kind === "draw" ? filters.drawNo : kind === "player" ? filters.player : filters.operator;
|
||||
@@ -1726,21 +1626,13 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
})}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">{t(`items.${selectedReport.key}.summary`)}</div>
|
||||
{reportTimeAxisKey(selectedReport.key) ? (
|
||||
<p className="text-xs text-muted-foreground">{t(`timeAxis.${reportTimeAxisKey(selectedReport.key)}`)}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 pt-0">
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
{selectedReport.fields.map(renderField)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 border-t border-border/60 pt-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="space-y-1 text-xs text-muted-foreground">
|
||||
<div>{t("filterPanel")}</div>
|
||||
<div>{t("queryHint")}</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<div className="flex justify-end gap-2 border-t border-border/60 pt-3">
|
||||
<Button type="button" variant="outline" size="sm" onClick={resetFilters}>
|
||||
{t("reset")}
|
||||
</Button>
|
||||
@@ -1756,7 +1648,6 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
<Database data-icon="inline-start" />
|
||||
{loading ? t("querying") : t("query")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -1770,70 +1661,34 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
))}
|
||||
</div>
|
||||
|
||||
{reportDisclaimerKey(selectedReport.key) ? (
|
||||
<div className="rounded-md border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-950 dark:border-amber-700 dark:bg-amber-950/30 dark:text-amber-100">
|
||||
{t(reportDisclaimerKey(selectedReport.key)!)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header flex flex-col gap-2 pb-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<CardTitle className="admin-list-title">{t("preview.title")}</CardTitle>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1.5">
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!canExportReports || exporting !== null}
|
||||
onClick={() => exportReport("csv")}
|
||||
>
|
||||
<FileDown data-icon="inline-start" />
|
||||
{t("formats.csvServer")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={!canExportReports || exporting !== null}
|
||||
onClick={() => exportReport("excel")}
|
||||
>
|
||||
<FileSpreadsheet data-icon="inline-start" />
|
||||
{t("formats.excelServer")}
|
||||
</Button>
|
||||
</div>
|
||||
{result && result.rows.length > 0 ? (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground">{t("exportPreviewHint")}</p>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={!canExportReports || exporting !== null}
|
||||
onClick={() => exportPreview("csv")}
|
||||
>
|
||||
{t("formats.csvPreview")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={!canExportReports || exporting !== null}
|
||||
onClick={() => exportPreview("excel")}
|
||||
>
|
||||
{t("formats.excelPreview")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 pt-3">
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50/70 px-3 py-2 text-xs text-amber-950">
|
||||
{t("preview.summaryScopeHint")}
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!canExportReports || exporting !== null}
|
||||
onClick={() => void exportReport("csv")}
|
||||
>
|
||||
<FileDown data-icon="inline-start" />
|
||||
{t("formats.csv")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={!canExportReports || exporting !== null}
|
||||
onClick={() => void exportReport("excel")}
|
||||
>
|
||||
<FileSpreadsheet data-icon="inline-start" />
|
||||
{t("formats.excel")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 pt-3">
|
||||
<Table id="reports-preview-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
|
||||
Reference in New Issue
Block a user