feat(agents, config, dashboard, i18n): add agent line provision wizard, site deletion, and site dashboard with multi-language support

Added agent line provision wizard page with permission gating, replacing redirect placeholder. Introduced site deletion API and UI with confirmation dialog in integration sites management. Added new site-scoped dashboard panel showing bet metrics, P/L trends, active players, and quick links. Enhanced chart tooltip to support custom formatters and fix indicator color
This commit is contained in:
2026-06-12 20:47:53 +08:00
parent 24fd7c10bd
commit 6ea0a6feec
48 changed files with 1573 additions and 629 deletions

View File

@@ -27,11 +27,14 @@ import type { AdminAgentLineProvisionResult } from "@/types/api/admin-agent-line
type AgentLineProvisionWizardProps = {
embedded?: boolean;
/** 预选接入站点(如代理线路页当前选中的站点) */
defaultSiteCode?: string;
onSuccess?: (result: AdminAgentLineProvisionResult) => void | Promise<void>;
};
export function AgentLineProvisionWizard({
embedded = false,
defaultSiteCode,
onSuccess,
}: AgentLineProvisionWizardProps): React.ReactElement {
const { t } = useTranslation(["agents", "common"]);
@@ -40,7 +43,6 @@ export function AgentLineProvisionWizard({
const [sites, setSites] = useState<AdminIntegrationSiteRow[]>([]);
const [form, setForm] = useState({
site_code: "",
code: "",
name: "",
username: "",
password: "",
@@ -64,24 +66,22 @@ export function AgentLineProvisionWizard({
[sites],
);
useAsyncEffect(() => {
if (sitesLoading || form.site_code !== "" || !defaultSiteCode) {
return;
}
const normalized = defaultSiteCode.trim().toLowerCase();
if (unboundSites.some((row) => row.code.toLowerCase() === normalized)) {
setForm((f) => ({ ...f, site_code: normalized }));
}
}, [defaultSiteCode, form.site_code, sitesLoading, unboundSites]);
async function onSubmit(e: React.FormEvent): Promise<void> {
e.preventDefault();
if (!form.site_code.trim()) {
toast.error(t("agents:lineProvision.siteRequired", { defaultValue: "请选择接入站点" }));
return;
}
if (!form.code.trim()) {
toast.error(t("agents:lineProvision.codeRequired", { defaultValue: "请填写代理编码" }));
return;
}
if (!/^[a-z0-9][a-z0-9_-]*$/i.test(form.code.trim())) {
toast.error(
t("agents:lineProvision.codePatternInvalid", {
defaultValue: "代理编码仅支持字母、数字、下划线和中划线,且需以字母或数字开头",
}),
);
return;
}
if (!form.name.trim()) {
toast.error(t("agents:nameRequired", { defaultValue: "请填写代理名称" }));
return;
@@ -152,7 +152,6 @@ export function AgentLineProvisionWizard({
try {
const result = await postAdminAgentLine({
site_code: form.site_code.trim().toLowerCase(),
code: form.code.trim().toLowerCase(),
name: form.name.trim(),
username: form.username.trim(),
password: form.password,
@@ -165,7 +164,6 @@ export function AgentLineProvisionWizard({
toast.success(t("agents:lineProvision.success", { defaultValue: "一级代理已创建" }));
setForm((f) => ({
site_code: "",
code: "",
name: "",
username: "",
password: "",
@@ -200,7 +198,7 @@ export function AgentLineProvisionWizard({
<p className="mb-4 max-w-xl text-sm text-muted-foreground">
{t("agents:lineProvision.description", {
defaultValue:
"将一级代理绑定到已有接入站点,并配置后台登录账号与占成、授信、回水。代理编码创建后不可修改。",
"将一级代理绑定到已有接入站点,并配置后台登录账号与占成、授信、回水。",
})}{" "}
<Link
href="/admin/config/integration-sites"
@@ -247,15 +245,6 @@ export function AgentLineProvisionWizard({
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>{t("agents:lineProvision.code", { defaultValue: "代理编码" })}</Label>
<Input
value={form.code}
onChange={(e) => setForm((f) => ({ ...f, code: e.target.value }))}
required
pattern="[a-z0-9][a-z0-9_-]*"
/>
</div>
<div className="grid gap-2">
<Label>{t("agents:lineProvision.name", { defaultValue: "一级代理名称" })}</Label>
<Input

View File

@@ -17,9 +17,11 @@ import {
AgentLineDetailPanel,
type AgentDetailTab,
} from "@/modules/agents/agent-line-detail-panel";
import { AgentLineProvisionWizard } from "@/modules/agents/agent-line-provision-wizard";
import { AgentLineSidebar } from "@/modules/agents/agent-line-sidebar";
import { AgentProfileFields } from "@/modules/agents/agent-profile-fields";
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { AdminNoIntegrationSiteState } from "@/components/admin/admin-no-integration-site-state";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -47,6 +49,7 @@ import {
PRD_USERS_MANAGE,
} from "@/lib/admin-prd";
import { useAdminSiteCodeOptions } from "@/hooks/use-admin-site-code-options";
import { isSiteAdminOperator } from "@/lib/admin-session-variants";
import { useAdminProfile } from "@/stores/admin-session";
import { useAgentManagementSiteStore } from "@/stores/agent-management-site";
import type { AgentNodeRow, AgentParentCaps, AgentProfileRow } from "@/types/api/admin-agent";
@@ -102,7 +105,7 @@ export function AgentsConsole(): React.ReactElement {
boundAgent === null &&
(isSuperAdmin ||
adminHasAnyPermission(profile?.permissions, [...PRD_AGENT_LINE_PROVISION_ACCESS_ANY]));
const { sites: siteOptions } = useAdminSiteCodeOptions();
const { sites: siteOptions, loading: sitesLoading } = useAdminSiteCodeOptions();
const adminSiteId = useAgentManagementSiteStore((s) => s.adminSiteId);
const setAdminSiteId = useAgentManagementSiteStore((s) => s.setAdminSiteId);
const [tree, setTree] = useState<AgentNodeRow[]>([]);
@@ -241,10 +244,17 @@ export function AgentsConsole(): React.ReactElement {
[flatNodes],
);
const visibleAgentRows = flatNodes;
const selectedSiteLabel = useMemo(
() => siteOptions.find((site) => site.id === adminSiteId)?.name ?? null,
[adminSiteId, siteOptions],
);
const boundSite = profile?.site ?? null;
const selectedSiteLabel = useMemo(() => {
const fromOptions = siteOptions.find((site) => site.id === adminSiteId)?.name;
if (fromOptions) {
return fromOptions;
}
if (boundSite != null && boundSite.id === adminSiteId) {
return boundSite.name;
}
return null;
}, [adminSiteId, boundSite, siteOptions]);
const activeSiteCode = useMemo(() => {
const fromAgent = boundAgent?.site_code?.trim();
if (fromAgent) {
@@ -254,8 +264,11 @@ export function AgentsConsole(): React.ReactElement {
if (fromSite) {
return fromSite;
}
if (boundSite != null && boundSite.id === adminSiteId) {
return boundSite.code.trim();
}
return flatNodes.find((node) => node.depth === 0)?.code?.trim() ?? "";
}, [adminSiteId, boundAgent?.site_code, flatNodes, siteOptions]);
}, [adminSiteId, boundAgent?.site_code, boundSite, flatNodes, siteOptions]);
const rootNode = useMemo(
() => flatNodes.find((node) => node.is_root || node.depth === 0) ?? null,
[flatNodes],
@@ -319,11 +332,23 @@ export function AgentsConsole(): React.ReactElement {
setAdminSiteId(profile.agent.admin_site_id);
return;
}
if (profile?.site?.id) {
setAdminSiteId(profile.site.id);
return;
}
if (siteOptions.length > 0 && isSuperAdmin) {
setAdminSiteId(siteOptions[0]?.id ?? null);
}
}
}, [adminSiteId, canViewAgents, isSuperAdmin, profile?.agent?.admin_site_id, setAdminSiteId, siteOptions]);
}, [
adminSiteId,
canViewAgents,
isSuperAdmin,
profile?.agent?.admin_site_id,
profile?.site?.id,
setAdminSiteId,
siteOptions,
]);
useEffect(() => {
if (!Number.isInteger(selectedNodeIdFromUrl) || selectedNodeIdFromUrl <= 0) {
@@ -786,10 +811,60 @@ export function AgentsConsole(): React.ReactElement {
);
}
if (canViewAgents && loading && tree.length === 0) {
const hasSiteContext =
siteOptions.length > 0 ||
profile?.site != null ||
(profile?.accessible_sites?.length ?? 0) > 0;
if (canViewAgents && profile?.agent == null && !sitesLoading && !hasSiteContext) {
return <AdminNoIntegrationSiteState canCreate={isSuperAdmin} />;
}
if (canViewAgents && loading && tree.length === 0 && adminSiteId !== null) {
return <AdminLoadingState label={t("listTitle", { defaultValue: "代理列表" })} />;
}
const showSiteAdminAwaitingRoot =
!loading &&
flatNodes.length === 0 &&
!canProvisionLine &&
isSiteAdminOperator(profile);
if (showSiteAdminAwaitingRoot) {
return (
<div className="rounded-2xl border border-border/70 bg-card px-5 py-10 text-center shadow-sm">
<p className="text-sm font-medium text-foreground">
{t("lineUi.awaitingRootAgentTitle", {
defaultValue: "本站尚未开通一级代理",
})}
</p>
<p className="mx-auto mt-2 max-w-md text-sm text-muted-foreground">
{t("lineUi.awaitingRootAgentHint", {
defaultValue:
"一级代理需由平台超级管理员在「开通一级代理」中创建。开通后您可在此管理下级代理、占成与授信。",
})}
</p>
</div>
);
}
const showProvisionEmpty =
!loading && flatNodes.length === 0 && canProvisionLine;
if (showProvisionEmpty) {
return (
<div className="flex min-h-[32rem] flex-col gap-0">
<AgentLineProvisionWizard
embedded
defaultSiteCode={activeSiteCode}
onSuccess={async () => {
await loadTree(adminSiteId);
}}
/>
</div>
);
}
return (
<div className="flex min-h-[32rem] flex-col gap-0">
<ConfirmDialog />
@@ -861,9 +936,13 @@ export function AgentsConsole(): React.ReactElement {
</div>
) : (
<div className="rounded-2xl border border-border/70 bg-card px-5 py-8 text-sm text-muted-foreground shadow-sm">
{t("lineUi.provisionOnlyHint", {
defaultValue: "当前账号仅可开通一级代理线路,请前往「开通一级代理」页面创建新线路。",
})}
{flatNodes.length === 0
? t("lineUi.noRootAgentHint", {
defaultValue: "该站点尚未开通一级代理,请联系平台管理员在「开通一级代理」中创建线路。",
})
: t("lineUi.provisionOnlyHint", {
defaultValue: "当前账号仅可开通一级代理线路,请前往「开通一级代理」页面创建新线路。",
})}
</div>
)}

View File

@@ -12,6 +12,7 @@ import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { formatAdminCreditMajorDecimal } from "@/lib/money";
import {
Select,
SelectContent,
@@ -44,7 +45,7 @@ function formatCredit(value: number | null | undefined): string {
return "-";
}
return new Intl.NumberFormat("zh-CN", { maximumFractionDigits: 0 }).format(value);
return formatAdminCreditMajorDecimal(value);
}
function statusLabel(status: number, t: (key: string, options?: { defaultValue?: string }) => string): string {

View File

@@ -35,7 +35,7 @@ export function AgentsSubnav(): React.ReactElement {
adminHasAnyPermission(profile?.permissions, [...PRD_INTEGRATION_ACCESS_ANY]);
useEffect(() => {
if (adminSiteId !== null || siteOptions.length === 0) {
if (adminSiteId !== null) {
return;
}
const boundSiteId = profile?.agent?.admin_site_id;
@@ -43,14 +43,26 @@ export function AgentsSubnav(): React.ReactElement {
setAdminSiteId(boundSiteId);
return;
}
setAdminSiteId(siteOptions[0]?.id ?? null);
}, [adminSiteId, profile?.agent?.admin_site_id, setAdminSiteId, siteOptions]);
if (profile?.site?.id) {
setAdminSiteId(profile.site.id);
return;
}
if (siteOptions.length > 0) {
setAdminSiteId(siteOptions[0]?.id ?? null);
}
}, [adminSiteId, profile?.agent?.admin_site_id, profile?.site?.id, setAdminSiteId, siteOptions]);
const selectSiteId = adminSiteId ?? siteOptions[0]?.id ?? null;
const selectSiteId = adminSiteId ?? profile?.site?.id ?? siteOptions[0]?.id ?? null;
const selectedSite = useMemo(() => {
const site = siteOptions.find((item) => item.id === selectSiteId);
return site ?? null;
}, [selectSiteId, siteOptions]);
if (site) {
return site;
}
if (profile?.site != null && profile.site.id === selectSiteId) {
return profile.site;
}
return null;
}, [profile?.site, selectSiteId, siteOptions]);
const filteredSites = useMemo(() => {
const normalized = deferredKeyword.trim().toLowerCase();
@@ -61,6 +73,16 @@ export function AgentsSubnav(): React.ReactElement {
return siteOptions.filter((site) => site.name.toLowerCase().includes(normalized));
}, [deferredKeyword, siteOptions]);
const siteReadOnlyLabel =
pathname !== "/admin/agents/list" &&
!canSwitchSite &&
selectedSite != null ? (
<div className="flex h-10 min-w-[200px] items-center justify-end gap-2 rounded-md border border-border/70 bg-background px-3 text-sm">
<span className="min-w-0 truncate font-medium text-foreground">{selectedSite.name}</span>
<span className="shrink-0 text-xs text-muted-foreground">{selectedSite.code}</span>
</div>
) : null;
const siteSelector =
pathname !== "/admin/agents/list" && canSwitchSite && siteOptions.length > 0 && selectSiteId !== null ? (
<Popover open={sitePickerOpen} onOpenChange={setSitePickerOpen}>
@@ -129,7 +151,7 @@ export function AgentsSubnav(): React.ReactElement {
) : null;
return (
<AdminSubnavBar trailing={siteSelector}>
<AdminSubnavBar trailing={siteSelector ?? siteReadOnlyLabel}>
<div className="pb-1">
<p className="text-sm font-medium text-foreground">
{t("title", { defaultValue: "代理管理" })}

View File

@@ -1,20 +1,8 @@
"use client";
import Link from "next/link";
import { useCallback, useMemo, useState, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import {
BarChart3,
Flame,
Landmark,
Network,
RefreshCw,
Sparkles,
Ticket,
TrendingUp,
Users,
Wallet,
} from "lucide-react";
import { BarChart3, RefreshCw, TrendingUp, Users, Wallet } from "lucide-react";
import { getAdminDashboard } from "@/api/admin-dashboard";
import { useAsyncEffect } from "@/hooks/use-async-effect";
@@ -22,24 +10,18 @@ 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_AGENT_HUB_ACCESS_ANY,
PRD_PLAYERS_ACCESS_ANY,
PRD_REPORTS_VIEW_ACCESS_ANY,
PRD_SETTLEMENT_AGENT_ACCESS_ANY,
PRD_TICKETS_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 { cn } from "@/lib/utils";
import { useAdminProfile } from "@/stores/admin-session";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button, buttonVariants } from "@/components/ui/button";
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 {
formatDashboardCreditMajor,
formatDashboardMoneyMinor,
@@ -49,14 +31,27 @@ import type { AdminDashboardAgentOverview } 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>
);
}
export function AgentDashboardConsole(): ReactElement {
const { t, i18n } = useTranslation(["dashboard", "common", "agents"]);
const tRef = useTranslationRef(["dashboard", "common"]);
const formatDt = useAdminDateTimeFormatter();
const profile = useAdminProfile();
const agent = profile?.agent ?? null;
const permissions = useMemo(() => profile?.permissions ?? [], [profile?.permissions]);
const todayLabel = useMemo(() => {
const locale = normalizeAdminLanguage(i18n.resolvedLanguage ?? i18n.language);
const weekday = t(`date.weekdays.${adminWeekdayKeyForDate()}`, { ns: "common" });
@@ -118,47 +113,6 @@ export function AgentDashboardConsole(): ReactElement {
const currency = "NPR";
const displayCurrency = overview?.currency_code ?? currency;
const quickLinks = useMemo(() => {
const links: { href: string; label: string; icon: ReactElement }[] = [];
if (adminHasAnyPermission(permissions, [...PRD_TICKETS_ACCESS_ANY])) {
links.push({
href: "/admin/tickets",
label: t("agent.quickLinks.tickets"),
icon: <Ticket className="size-4" />,
});
}
if (adminHasAnyPermission(permissions, [...PRD_PLAYERS_ACCESS_ANY])) {
links.push({
href: "/admin/players",
label: t("agent.quickLinks.players"),
icon: <Users className="size-4" />,
});
}
if (adminHasAnyPermission(permissions, [...PRD_REPORTS_VIEW_ACCESS_ANY])) {
links.push({
href: "/admin/reports",
label: t("agent.quickLinks.reports"),
icon: <BarChart3 className="size-4" />,
});
}
if (adminHasAnyPermission(permissions, [...PRD_AGENT_HUB_ACCESS_ANY])) {
links.push({
href: "/admin/agents",
label: t("agent.quickLinks.agents"),
icon: <Network className="size-4" />,
});
}
if (adminHasAnyPermission(permissions, [...PRD_SETTLEMENT_AGENT_ACCESS_ANY])) {
links.push({
href: "/admin/settlement-center",
label: t("agent.quickLinks.bills"),
icon: <Wallet className="size-4" />,
});
}
return links;
}, [permissions, t]);
return (
<div className="flex min-w-0 w-full max-w-none flex-col gap-5">
<div className="flex flex-wrap items-center justify-between gap-2">
@@ -191,270 +145,134 @@ export function AgentDashboardConsole(): ReactElement {
) : null}
{loading ? (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-28 rounded-xl" />
<Skeleton key={i} className="h-24 rounded-xl" />
))}
</div>
) : overview ? (
<section className="space-y-4">
<div className="grid min-w-0 grid-cols-1 gap-4 xl:grid-cols-[1.35fr_0.95fr]">
<Card className="overflow-hidden border-slate-200 bg-[radial-gradient(circle_at_top_left,_rgba(16,185,129,0.18),_transparent_42%),linear-gradient(135deg,_#0f172a,_#111827_55%,_#1f2937)] text-white shadow-[0_20px_60px_-25px_rgba(15,23,42,0.65)]">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-[0.24em] text-emerald-200/80">
{t("agent.heroEyebrow")}
</p>
<CardTitle className="mt-2 text-2xl font-semibold">
{t("agent.heroTitle", { name: overview.agent_name || overview.agent_code })}
</CardTitle>
</div>
<div className="rounded-full border border-white/15 bg-white/10 p-2">
<Sparkles className="size-4 text-emerald-200" />
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2 xl: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")
}
/>
<DashboardKpiCard
label={t("agent.todayShareProfit")}
value={formatDashboardSignedMoneyMinor(overview.today_profit_minor, displayCurrency)}
icon={<BarChart3 className="size-4" />}
hint={t("agent.shareRate", { rate: overview.total_share_rate })}
valueClassName={signedMoneyClass(overview.today_profit_minor, true)}
/>
<DashboardKpiCard
label={t("agent.activePlayersToday")}
value={overview.active_player_count_today}
icon={<Users className="size-4" />}
hint={t("agent.betOrdersTodayHint", { count: overview.bet_order_count_today })}
/>
<DashboardKpiCard
label={t("agent.pendingBills")}
value={overview.pending_bill_count}
icon={<Wallet className="size-4" />}
hint={t("agent.pendingUnpaid", {
amount: formatDashboardMoneyMinor(overview.pending_unpaid_minor, displayCurrency),
})}
accent={overview.pending_bill_count > 0 ? "destructive" : "muted"}
/>
</div>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold">{t("agent.creditTitle")}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div>
<p className="text-2xl font-semibold tabular-nums">
{formatDashboardCreditMajor(overview.credit_limit, displayCurrency)}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{t("agent.creditAvailable", {
amount: formatDashboardCreditMajor(overview.available_credit, displayCurrency),
})}
</p>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<AgentMetric
label={t("agent.creditAllocatedLabel")}
value={formatDashboardCreditMajor(overview.allocated_credit, displayCurrency)}
/>
<AgentMetric
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", {
depth: overview.depth,
childAgent: overview.can_create_child_agent ? t("agent.yes") : t("agent.no"),
player: overview.can_create_player ? t("agent.yes") : t("agent.no"),
})}
</p>
</CardContent>
</Card>
<div className="grid gap-3 md:grid-cols-2">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">{t("agent.sevenDayTitle")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-2xl border border-white/10 bg-white/8 p-4 backdrop-blur">
<p className="text-xs text-slate-300">{t("agent.todayBet")}</p>
<p className="mt-2 text-2xl font-semibold tabular-nums">
{formatDashboardMoneyMinor(overview.today_bet_minor, displayCurrency)}
</p>
</div>
<div className="rounded-2xl border border-white/10 bg-white/8 p-4 backdrop-blur">
<p className="text-xs text-slate-300">{t("agent.todayPayout")}</p>
<p className="mt-2 text-2xl font-semibold tabular-nums">
{formatDashboardMoneyMinor(overview.today_payout_minor, displayCurrency)}
</p>
</div>
<div className="rounded-2xl border border-white/10 bg-white/8 p-4 backdrop-blur">
<p className="text-xs text-slate-300">{t("agent.todayShareProfit")}</p>
<p className="mt-2 text-2xl font-semibold tabular-nums">
{formatDashboardSignedMoneyMinor(overview.today_profit_minor, displayCurrency)}
</p>
</div>
<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="grid gap-3 sm:grid-cols-3">
<div className="rounded-xl border border-white/10 px-4 py-3">
<p className="text-[11px] text-slate-300">{t("agent.activePlayersToday")}</p>
<p className="mt-1 text-lg font-semibold tabular-nums">{overview.active_player_count_today}</p>
</div>
<div className="rounded-xl border border-white/10 px-4 py-3">
<p className="text-[11px] text-slate-300">{t("agent.betOrdersToday")}</p>
<p className="mt-1 text-lg font-semibold tabular-nums">{overview.bet_order_count_today}</p>
</div>
<div className="rounded-xl border border-white/10 px-4 py-3">
<p className="text-[11px] text-slate-300">{t("agent.pendingBills")}</p>
<p className="mt-1 text-lg font-semibold tabular-nums">{overview.pending_bill_count}</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-slate-300">
<span>{t("agent.shareRate", { rate: overview.total_share_rate })}</span>
<span>
{overview.latest_bet_at
? t("agent.latestBetAt", { time: formatDt(overview.latest_bet_at) })
: t("agent.noBetToday")}
<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>
</Card>
<Card className="border-slate-200 bg-[linear-gradient(180deg,_rgba(248,250,252,0.98),_rgba(241,245,249,0.92))]">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold">{t("agent.creditTitle")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<p className="text-3xl font-semibold tabular-nums text-slate-900">
{formatDashboardCreditMajor(overview.credit_limit, displayCurrency)}
</p>
<p className="mt-1 text-xs text-slate-500">
{t("agent.creditAvailable", {
amount: formatDashboardCreditMajor(overview.available_credit, displayCurrency),
})}
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-2xl border bg-white px-4 py-3">
<p className="text-xs text-slate-500">{t("agent.creditAllocatedLabel")}</p>
<p className="mt-1 text-lg font-semibold tabular-nums">
{formatDashboardCreditMajor(overview.allocated_credit, displayCurrency)}
</p>
</div>
<div className="rounded-2xl border bg-white px-4 py-3">
<p className="text-xs text-slate-500">{t("agent.creditUsedLabel")}</p>
<p className="mt-1 text-lg font-semibold tabular-nums">
{formatDashboardCreditMajor(overview.used_credit, displayCurrency)}
</p>
</div>
</div>
<p className="text-xs text-slate-500">
{t("agent.pendingUnpaid", {
amount: formatDashboardMoneyMinor(overview.pending_unpaid_minor, displayCurrency),
})}
</p>
</CardContent>
</Card>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<TrendingUp className="size-4 text-emerald-600" />
{t("agent.sevenDayTitle")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-1 text-sm">
<p className="text-xl font-semibold tabular-nums">
{formatDashboardMoneyMinor(overview.seven_day_bet_minor, displayCurrency)}
</p>
<p className="text-xs text-muted-foreground">
{t("agent.sevenDayPayout", {
amount: formatDashboardMoneyMinor(overview.seven_day_payout_minor, displayCurrency),
})}
</p>
<p className="text-xs text-muted-foreground">
{t("agent.sevenDayShareProfit", {
amount: formatDashboardSignedMoneyMinor(overview.seven_day_profit_minor, displayCurrency),
})}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<Users className="size-4 text-sky-600" />
{t("agent.teamTitle")}
</CardTitle>
<CardTitle className="text-sm font-semibold">{t("agent.teamTitle")}</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-3 text-sm">
<div>
<p className="text-xs text-muted-foreground">{t("agent.directChildren")}</p>
<p className="text-xl font-semibold tabular-nums">{overview.direct_child_count}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">{t("agent.subtreeAgents")}</p>
<p className="text-xl font-semibold tabular-nums">{overview.subtree_agent_count}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">{t("agent.directPlayers")}</p>
<p className="text-xl font-semibold tabular-nums">{overview.direct_player_count}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">{t("agent.teamPlayers")}</p>
<p className="text-xl font-semibold tabular-nums">{overview.team_player_count}</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<Wallet className="size-4 text-amber-600" />
{t("agent.pendingBills")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-1">
<p className="text-2xl font-semibold tabular-nums">{overview.pending_bill_count}</p>
<p className="text-xs text-muted-foreground">
{t("agent.pendingUnpaid", {
amount: formatDashboardMoneyMinor(overview.pending_unpaid_minor, displayCurrency),
})}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<Flame className="size-4 text-rose-600" />
{t("agent.topMomentum")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-1">
{overview.top_agent_today ? (
<>
<p className="text-sm font-semibold text-slate-900">
{overview.top_agent_today.agent_name || overview.top_agent_today.agent_code}
</p>
<p className="text-xl font-semibold tabular-nums">
{formatDashboardMoneyMinor(overview.top_agent_today.total_bet_minor, displayCurrency)}
</p>
<p className="text-xs text-muted-foreground">
{t("agent.topMomentumPayout", {
amount: formatDashboardMoneyMinor(
overview.top_agent_today.total_payout_minor,
displayCurrency,
),
})}
</p>
</>
) : (
<p className="text-sm text-muted-foreground">{t("agent.noBetToday")}</p>
)}
</CardContent>
</Card>
</div>
<div className="grid gap-4 xl:grid-cols-[1.15fr_0.85fr]">
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<Landmark className="size-4 text-slate-700" />
{t("agent.managementFocus")}
</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-3">
<div className="rounded-2xl border bg-slate-50 px-4 py-3">
<p className="text-xs text-slate-500">{t("agent.focusBet")}</p>
<p className="mt-1 text-lg font-semibold tabular-nums">
{formatDashboardMoneyMinor(overview.today_bet_minor, displayCurrency)}
</p>
</div>
<div className="rounded-2xl border bg-slate-50 px-4 py-3">
<p className="text-xs text-slate-500">{t("agent.focusPlayers")}</p>
<p className="mt-1 text-lg font-semibold tabular-nums">{overview.active_player_count_today}</p>
</div>
<div className="rounded-2xl border bg-slate-50 px-4 py-3">
<p className="text-xs text-slate-500">{t("agent.focusBills")}</p>
<p className="mt-1 text-lg font-semibold tabular-nums">{overview.pending_bill_count}</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">{t("agent.quickStatsTitle")}</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">{t("agent.canCreateChildAgent")}</span>
<span className="font-medium">
{overview.can_create_child_agent ? t("agent.yes") : t("agent.no")}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">{t("agent.canCreatePlayer")}</span>
<span className="font-medium">
{overview.can_create_player ? t("agent.yes") : t("agent.no")}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">{t("agent.lineDepth")}</span>
<span className="font-medium tabular-nums">{overview.depth}</span>
</div>
{adminHasAnyPermission(permissions, [...PRD_SETTLEMENT_AGENT_ACCESS_ANY]) ? (
<Link
href="/admin/settlement-center"
className={cn(buttonVariants({ variant: "link", size: "sm" }), "h-auto px-0")}
>
{t("agent.viewBills")}
</Link>
) : null}
<AgentMetric
label={t("agent.directChildren")}
value={String(overview.direct_child_count)}
/>
<AgentMetric
label={t("agent.directPlayers")}
value={String(overview.direct_player_count)}
/>
<AgentMetric
label={t("agent.subtreeAgents")}
value={String(overview.subtree_agent_count)}
/>
<AgentMetric
label={t("agent.teamPlayers")}
value={String(overview.team_player_count)}
/>
</CardContent>
</Card>
</div>
@@ -480,21 +298,6 @@ export function AgentDashboardConsole(): ReactElement {
<AlertDescription>{t("warnings.drawPermission")}</AlertDescription>
</Alert>
)}
{quickLinks.length > 0 ? (
<section className="flex flex-wrap gap-2">
{quickLinks.map((link) => (
<Link
key={link.href}
href={link.href}
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-8 gap-1.5")}
>
{link.icon}
{link.label}
</Link>
))}
</section>
) : null}
</div>
);
}

View File

@@ -21,6 +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 { cn } from "@/lib/utils";
import { DashboardKpiCard } from "@/modules/dashboard/dashboard-visuals";
import { DASHBOARD_CHART_COLORS } from "@/modules/dashboard/dashboard-chart-config";
@@ -241,6 +242,7 @@ export function DashboardAnalyticsMain({ analytics }: { analytics: DashboardAnal
: t("analytics.summaryProfit")
}
value={formatSignedMoney(summary.approx_house_gross_minor, currency)}
valueClassName={signedMoneyClass(summary.approx_house_gross_minor, true)}
hint={
profitScope === "share_profit"
? t("analytics.shareProfitHint")
@@ -452,7 +454,14 @@ export function DashboardAgentRankingCard({
) : null}
</div>
</div>
<div className="shrink-0 text-right text-xs font-semibold tabular-nums">
<div
className={cn(
"shrink-0 text-right text-xs font-semibold tabular-nums",
rankingMetric === "profit"
? signedMoneyClass(row.approx_house_gross_minor, true)
: undefined,
)}
>
{formatRowValue(row)}
</div>
</div>

View File

@@ -2,22 +2,9 @@
import dynamic from "next/dynamic";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState, type ReactElement, type ReactNode } from "react";
import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import {
AlertTriangle,
ClipboardList,
Diamond,
FileSearch,
RefreshCw,
ScrollText,
Settings,
Shield,
Ticket,
Wallet,
BarChart3,
Scale,
} from "lucide-react";
import { AlertTriangle, ClipboardList, RefreshCw, Shield, Wallet } from "lucide-react";
import { getAdminDashboardByScope } from "@/api/admin-dashboard";
import { useAdminPlayTypeCatalog } from "@/hooks/use-admin-play-type-catalog";
@@ -275,23 +262,6 @@ export function DashboardConsole(): ReactElement {
});
const showAnalytics = canFinance;
const quickLinks: { href: string; label: string; icon: ReactNode }[] = [
{ href: "/admin/draws", label: t("quickLinks.createDrawPlan"), icon: <Diamond className="size-4" /> },
{ href: "/admin/draws", label: t("quickLinks.drawSchedule"), icon: <Ticket className="size-4" /> },
{
href: drawId != null ? `/admin/draws/${drawId}/results` : "/admin/draws",
label: t("quickLinks.results"),
icon: <FileSearch className="size-4" />,
},
{ href: "/admin/tickets", label: t("quickLinks.tickets"), icon: <Shield className="size-4" /> },
{ href: "/admin/wallet/transactions", label: t("quickLinks.walletTransactions"), icon: <Wallet className="size-4" /> },
{ href: "/admin/audit-logs", label: t("quickLinks.auditLogs"), icon: <ScrollText className="size-4" /> },
{ href: "/admin/reports", label: t("quickLinks.reports"), icon: <BarChart3 className="size-4" /> },
{ href: "/admin/rules/odds", label: t("quickLinks.payoutRules"), icon: <Scale className="size-4" /> },
{ href: "/admin/risk", label: t("quickLinks.riskMonitor"), icon: <Shield className="size-4" /> },
{ href: "/admin/settings", label: t("quickLinks.systemSettings"), icon: <Settings className="size-4" /> },
];
return (
<div className="flex min-w-0 w-full max-w-none flex-col gap-5">
<div className="flex flex-wrap items-center justify-between gap-2">
@@ -518,42 +488,20 @@ export function DashboardConsole(): ReactElement {
</div>
{!showAnalytics ? (
<div className="grid min-w-0 grid-cols-1 gap-4 md:grid-cols-2">
<Card className="admin-list-card min-w-0 py-0">
<CardHeader className="border-b border-border/60 px-4 py-3 pb-0">
<CardTitle className="text-sm font-semibold">{t("financeStructure")}</CardTitle>
</CardHeader>
<CardContent className="px-4 py-4">
{loading ? (
<Skeleton className="h-52 w-full" />
) : finance ? (
<FinanceStructureChart finance={finance} formatMoney={formatMoneyMinor} />
) : (
<AdminNoResourceState className="py-10 text-center text-xs text-muted-foreground" />
)}
</CardContent>
</Card>
<Card className="admin-list-card min-w-0 py-0">
<CardHeader className="border-b border-border/60 px-4 py-3 pb-0">
<CardTitle className="text-sm font-semibold">{t("quickLinksTitle")}</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-2 px-4 py-4 sm:grid-cols-3">
{quickLinks.map((q) => (
<Link
key={q.href + q.label}
href={q.href}
className="flex min-w-0 flex-col items-center gap-2 rounded-lg border border-border/70 bg-muted/10 px-2 py-3 text-center text-[11px] font-medium leading-snug text-foreground transition-colors hover:border-primary/30 hover:bg-muted/30"
>
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-card text-primary shadow-sm">
{q.icon}
</span>
<span className="line-clamp-2">{q.label}</span>
</Link>
))}
</CardContent>
</Card>
</div>
<Card className="admin-list-card min-w-0 py-0">
<CardHeader className="border-b border-border/60 px-4 py-3 pb-0">
<CardTitle className="text-sm font-semibold">{t("financeStructure")}</CardTitle>
</CardHeader>
<CardContent className="px-4 py-4">
{loading ? (
<Skeleton className="h-52 w-full" />
) : finance ? (
<FinanceStructureChart finance={finance} formatMoney={formatMoneyMinor} />
) : (
<AdminNoResourceState className="py-10 text-center text-xs text-muted-foreground" />
)}
</CardContent>
</Card>
) : null}
</div>
@@ -576,26 +524,6 @@ export function DashboardConsole(): ReactElement {
)}
</CardContent>
</Card>
<Card className="admin-list-card py-0">
<CardHeader className="border-b border-border/60 px-4 py-3 pb-0">
<CardTitle className="text-sm font-semibold">{t("quickLinksTitle")}</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-2 px-4 py-4 sm:grid-cols-3">
{quickLinks.map((q) => (
<Link
key={q.href + q.label}
href={q.href}
className="flex min-w-0 flex-col items-center gap-2 rounded-lg border border-border/70 bg-muted/10 px-2 py-3 text-center text-[11px] font-medium leading-snug text-foreground transition-colors hover:border-primary/30 hover:bg-muted/30"
>
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-card text-primary shadow-sm">
{q.icon}
</span>
<span className="line-clamp-2">{q.label}</span>
</Link>
))}
</CardContent>
</Card>
</aside>
) : null}
</section>

View File

@@ -2,19 +2,23 @@
import type { ReactElement } from "react";
import { isAgentOperator, isSiteAdminOperator } from "@/lib/admin-session-variants";
import { AgentDashboardConsole } from "@/modules/dashboard/agent-dashboard-console";
import { DashboardConsole } from "@/modules/dashboard/dashboard-console";
import { SiteDashboardConsole } from "@/modules/dashboard/site-dashboard-console";
import { useAdminProfile } from "@/stores/admin-session";
/** 平台账号走全站仪表盘;绑定代理节点的经营账号走代理仪表盘。 */
/** 超管/平台账号走全站仪表盘;站点管理员走站点仪表盘;代理经营账号走代理仪表盘。 */
export function DashboardPageClient(): ReactElement {
const profile = useAdminProfile();
const isAgentOperator =
profile?.agent != null && profile.is_super_admin !== true;
if (isAgentOperator) {
if (isAgentOperator(profile)) {
return <AgentDashboardConsole />;
}
if (isSiteAdminOperator(profile)) {
return <SiteDashboardConsole />;
}
return <DashboardConsole />;
}

View File

@@ -12,6 +12,8 @@ import {
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import { signedMoneyClass } from "@/lib/admin-signed-money";
import { cn } from "@/lib/utils";
import { buildTrendChartConfig, DASHBOARD_CHART_COLORS } from "@/modules/dashboard/dashboard-chart-config";
import { DashboardChartEmpty } from "@/modules/dashboard/dashboard-chart-empty";
import type { AdminDashboardAnalyticsPlayRow } from "@/types/api/admin-dashboard-analytics";
@@ -332,7 +334,14 @@ export function PeriodCompareStrip({
<span className="text-sm font-medium text-foreground">{row.label}</span>
<span className="text-xs tabular-nums text-muted-foreground">{row.pctText}</span>
</div>
<div className="mb-1 text-sm font-semibold tabular-nums">{formatMoney(row.value, currency)}</div>
<div
className={cn(
"mb-1 text-sm font-semibold tabular-nums",
row.key === "profit" ? signedMoneyClass(row.value, true) : undefined,
)}
>
{formatMoney(row.value, currency)}
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full transition-[width] duration-500"

View File

@@ -186,6 +186,7 @@ export function DashboardKpiCard({
hint,
icon,
accent = "primary",
valueClassName,
sparklineValues,
deltaLabel,
}: {
@@ -194,6 +195,8 @@ export function DashboardKpiCard({
hint?: ReactNode;
icon: ReactNode;
accent?: DashboardKpiAccent;
/** 覆盖主数值颜色(如盈亏红绿) */
valueClassName?: string;
sparklineValues?: number[];
deltaLabel?: ReactNode;
}): ReactElement {
@@ -210,7 +213,12 @@ export function DashboardKpiCard({
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-muted-foreground">{label}</p>
<p className="mt-1 truncate text-xl font-bold tabular-nums tracking-tight text-foreground">
<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}

View File

@@ -0,0 +1,255 @@
"use client";
import { useCallback, useMemo, useState, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import { BarChart3, RefreshCw, TrendingUp, Users, Wallet } from "lucide-react";
import { getAdminDashboard } from "@/api/admin-dashboard";
import { useAsyncEffect } from "@/hooks/use-async-effect";
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 { normalizeAdminLanguage } from "@/i18n";
import { adminWeekdayKeyForDate, formatAdminCalendarToday } from "@/lib/admin-datetime";
import { signedMoneyClass } from "@/lib/admin-signed-money";
import { cn } from "@/lib/utils";
import { useAdminProfile } from "@/stores/admin-session";
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 {
formatDashboardMoneyMinor,
formatDashboardSignedMoneyMinor,
} from "@/modules/dashboard/use-dashboard-analytics";
import type { AdminDashboardSiteOverview } 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>
);
}
export function SiteDashboardConsole(): ReactElement {
const { t, i18n } = useTranslation(["dashboard", "common"]);
const tRef = useTranslationRef(["dashboard", "common"]);
const formatDt = useAdminDateTimeFormatter();
const profile = useAdminProfile();
const site = profile?.site ?? null;
const permissions = useMemo(() => profile?.permissions ?? [], [profile?.permissions]);
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 playOptions = useCachedPlayTypeOptions();
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [hall, setHall] = useState<DrawCurrentSnapshot | null>(null);
const [drawId, setDrawId] = useState<number | null>(null);
const [overview, setOverview] = useState<AdminDashboardSiteOverview | null>(null);
const analyticsScope = useMemo(
() => ({
siteCode: site?.code ?? overview?.site_code ?? "",
}),
[overview?.site_code, site?.code],
);
const canAnalytics = adminHasAnyPermission(permissions, [...PRD_REPORTS_VIEW_ACCESS_ANY]);
const load = useCallback(async (isRefresh = false) => {
if (isRefresh) {
setRefreshing(true);
} else {
setLoading(true);
}
setError(null);
try {
const d = await getAdminDashboard();
setHall(d.hall);
setOverview(d.site_overview);
if (d.resolved_draw != null) {
setDrawId(d.resolved_draw.id);
} else {
setDrawId(null);
}
} catch (e) {
const msg =
e instanceof LotteryApiBizError ? e.message : tRef.current("warnings.loadFailed");
setError(msg);
} finally {
setLoading(false);
setRefreshing(false);
}
}, [tRef]);
useAsyncEffect(() => {
void load(false);
}, []);
const displayCurrency = overview?.currency_code ?? "NPR";
return (
<div className="flex min-w-0 w-full max-w-none flex-col gap-5">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<h1 className="admin-list-title">{t("site.title")}</h1>
<p className="mt-0.5 text-xs text-muted-foreground">
{site
? t("site.subtitle", { name: site.name || site.code })
: todayLabel}
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="h-8"
disabled={loading || refreshing}
onClick={() => void load(true)}
>
<RefreshCw className={cn("size-3.5", refreshing && "animate-spin")} />
{t("actions.refresh", { ns: "common" })}
</Button>
</div>
{error ? (
<Alert className="border-amber-200 bg-amber-50 dark:border-amber-900/60 dark:bg-amber-950/30">
<AlertTitle>{t("notice")}</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
{loading ? (
<div className="grid gap-3 sm:grid-cols-2 xl: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">
<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")
}
/>
<DashboardKpiCard
label={t("site.todayProfit")}
value={formatDashboardSignedMoneyMinor(overview.today_profit_minor, displayCurrency)}
icon={<BarChart3 className="size-4" />}
hint={t("site.profitScopeHint")}
valueClassName={signedMoneyClass(overview.today_profit_minor, true)}
/>
<DashboardKpiCard
label={t("site.activePlayersToday")}
value={overview.active_player_count_today}
icon={<Users className="size-4" />}
hint={t("site.betOrdersTodayHint", { count: overview.bet_order_count_today })}
/>
<DashboardKpiCard
label={t("site.pendingBills")}
value={overview.pending_bill_count}
icon={<Wallet className="size-4" />}
hint={t("site.pendingUnpaid", {
amount: formatDashboardMoneyMinor(overview.pending_unpaid_minor, displayCurrency),
})}
accent={overview.pending_bill_count > 0 ? "destructive" : "muted"}
/>
</div>
<div className="grid gap-3 md:grid-cols-2">
<Card>
<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>
</Card>
<Card>
<CardHeader className="pb-2">
<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)} />
{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(
overview.top_agent_today.total_bet_minor,
displayCurrency,
),
})}
</div>
) : null}
</CardContent>
</Card>
</div>
</section>
) : null}
<DashboardCurrentDrawCard
key={`${hall?.draw_no ?? "empty"}:${loading ? "loading" : "ready"}`}
hall={hall}
drawId={drawId}
loading={loading}
/>
{canAnalytics ? (
<DashboardAnalyticsPanel
enabled={canAnalytics}
playOptions={playOptions}
scope={analyticsScope}
/>
) : null}
</div>
);
}

View File

@@ -28,6 +28,7 @@ import type { AdminDrawShowData } from "@/types/api/admin-draws";
import { canManageDrawResults } from "@/lib/draw-access";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { useAdminProfile } from "@/stores/admin-session";
import { signedMoneyClass } from "@/lib/admin-signed-money";
import { cn } from "@/lib/utils";
import { formatAdminMinorUnits } from "@/lib/money";
@@ -309,7 +310,15 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
</div>
<div className="rounded-lg border bg-muted/20 px-3 py-2.5">
<p className="text-xs font-medium text-muted-foreground">{t("overviewProfitLoss")}</p>
<p className="mt-1 font-mono text-sm tabular-nums">
<p
className={cn(
"mt-1 font-mono text-sm tabular-nums",
signedMoneyClass(
financeSummary?.approx_house_gross_minor ?? data.profit_loss_minor ?? 0,
true,
),
)}
>
{formatAdminMinorUnits(
financeSummary?.approx_house_gross_minor ?? data.profit_loss_minor ?? 0,
financeCurrency,

View File

@@ -24,6 +24,7 @@ import {
TableRow,
} from "@/components/ui/table";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { signedMoneyClass } from "@/lib/admin-signed-money";
import { cn } from "@/lib/utils";
import { useAdminProfile } from "@/stores/admin-session";
import { LotteryApiBizError } from "@/types/api/errors";
@@ -140,12 +141,7 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
</div>
<div>
<span className="text-muted-foreground">{t("grossProfit")}</span>
<p
className={cn(
"tabular-nums font-semibold",
data.approx_house_gross_minor >= 0 ? "text-emerald-600" : "text-destructive",
)}
>
<p className={cn("tabular-nums font-semibold", signedMoneyClass(data.approx_house_gross_minor, true))}>
{formatMoney(data.approx_house_gross_minor)}
</p>
</div>

View File

@@ -52,6 +52,7 @@ import {
import { formatAdminMinorUnits } from "@/lib/money";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { useExportLabels } from "@/hooks/use-export-labels";
import { signedMoneyClass } from "@/lib/admin-signed-money";
import { cn } from "@/lib/utils";
import { useAdminProfile } from "@/stores/admin-session";
import { LotteryApiBizError } from "@/types/api/errors";
@@ -441,7 +442,7 @@ export function DrawsIndexConsole() {
<TableCell
className={cn(
"text-center text-xs tabular-nums",
(row.profit_loss_minor ?? 0) < 0 ? "text-destructive" : "text-emerald-600",
signedMoneyClass(row.profit_loss_minor ?? 0, true),
)}
>
{row.profit_loss_minor != null

View File

@@ -1,6 +1,6 @@
"use client";
import { Copy, Download, Link2, Pencil, ShieldAlert } from "lucide-react";
import { Copy, Download, Link2, Pencil, ShieldAlert, Trash2 } from "lucide-react";
import { useCallback, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "@/hooks/use-async-effect";
@@ -8,6 +8,7 @@ import { useTranslationRef } from "@/hooks/use-translation-ref";
import { toast } from "sonner";
import {
deleteAdminIntegrationSite,
getAdminIntegrationSite,
getAdminIntegrationSiteExport,
getAdminIntegrationSites,
@@ -243,6 +244,8 @@ export function IntegrationSitesConsole({
const [editingId, setEditingId] = useState<number | null>(null);
const [form, setForm] = useState<FormState>(EMPTY_FORM);
const [rotateTarget, setRotateTarget] = useState<AdminIntegrationSiteRow | null>(null);
const [deleteTarget, setDeleteTarget] = useState<AdminIntegrationSiteRow | null>(null);
const [deleteBusy, setDeleteBusy] = useState(false);
const [rotateBusy, setRotateBusy] = useState(false);
const [secretsDialog, setSecretsDialog] = useState<{
siteCode: string;
@@ -388,6 +391,25 @@ export function IntegrationSitesConsole({
}
}
async function confirmDelete(): Promise<void> {
if (!deleteTarget || !canManage) return;
setDeleteBusy(true);
try {
await deleteAdminIntegrationSite(deleteTarget.id);
toast.success(t("integrationSites.deleteSuccess", { code: deleteTarget.code }));
secretsCacheRef.current.delete(deleteTarget.id);
setDeleteTarget(null);
await load();
} catch (error) {
toast.error(
error instanceof LotteryApiBizError ? error.message : t("integrationSites.deleteFailed"),
);
} finally {
setDeleteBusy(false);
}
}
function openConnectivity(row: AdminIntegrationSiteRow): void {
setConnectivityTarget(row);
setConnectivityPlayerId("10001");
@@ -519,7 +541,13 @@ export function IntegrationSitesConsole({
{loading ? (
<AdminLoadingState minHeight="8rem" label={t("integrationSites.loading")} />
) : items.length === 0 ? (
<AdminNoResourceState />
<AdminNoResourceState message={t("integrationSites.empty")}>
{canCreate ? (
<Button type="button" size="sm" onClick={openCreate}>
{t("integrationSites.create")}
</Button>
) : null}
</AdminNoResourceState>
) : (
<div className="overflow-x-auto">
<Table>
@@ -637,6 +665,14 @@ export function IntegrationSitesConsole({
hidden: !canManage,
onClick: () => setRotateTarget(row),
},
{
key: "delete",
label: t("integrationSites.delete"),
icon: Trash2,
destructive: true,
hidden: !canManage,
onClick: () => setDeleteTarget(row),
},
]}
/>
</TableCell>
@@ -850,6 +886,33 @@ export function IntegrationSitesConsole({
</DialogContent>
</Dialog>
<Dialog open={deleteTarget !== null} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("integrationSites.deleteConfirmTitle")}</DialogTitle>
<DialogDescription>
{t("integrationSites.deleteConfirmDescription", {
code: deleteTarget?.code ?? "",
name: deleteTarget?.name ?? "",
})}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setDeleteTarget(null)}>
{t("integrationSites.cancel")}
</Button>
<Button
type="button"
variant="destructive"
disabled={deleteBusy}
onClick={() => void confirmDelete()}
>
{deleteBusy ? t("integrationSites.deleting") : t("integrationSites.deleteConfirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={connectivityTarget !== null}
onOpenChange={(open) => {

View File

@@ -75,6 +75,7 @@ import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { formatAdminInstant } from "@/lib/admin-datetime";
import { getAdminRequestLocale } from "@/lib/admin-locale";
import { signedMoneyClass } from "@/lib/admin-signed-money";
import { cn } from "@/lib/utils";
import { formatAdminMinorUnits } from "@/lib/money";
import { LotteryApiBizError } from "@/types/api/errors";
@@ -461,6 +462,10 @@ function formatPlainMoney(value: number, currencyCode: string | null | undefined
return formatAdminMinorUnits(value, currencyCode || "NPR");
}
function signedProfitCell(amount: number, currencyCode: string | null | undefined): string {
return cn("text-center tabular-nums", signedMoneyClass(amount, true));
}
function formatUsagePercent(ratio: number | null | undefined): string {
return ratio == null ? "-" : `${Math.round(ratio * 100)}%`;
}
@@ -906,6 +911,10 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
payload.items.reduce((sum, item) => sum - item.net_win_loss_minor, 0),
"NPR",
),
tone: (() => {
const houseGross = payload.items.reduce((sum, item) => sum - item.net_win_loss_minor, 0);
return houseGross >= 0 ? "good" : "bad";
})(),
},
{ label: t("preview.stats.players"), value: String(new Set(payload.items.map((item) => item.player_id)).size) },
],
@@ -1445,7 +1454,9 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
<TableCell className="text-center">{summary.ticket_item_count}</TableCell>
<TableCell className="text-center">{formatPlainMoney(summary.total_bet_minor, summary.currency_code)}</TableCell>
<TableCell className="text-center">{formatPlainMoney(summary.total_payout_minor, summary.currency_code)}</TableCell>
<TableCell className="text-center">{formatPlainMoney(summary.approx_house_gross_minor, summary.currency_code)}</TableCell>
<TableCell className={signedProfitCell(summary.approx_house_gross_minor, summary.currency_code)}>
{formatPlainMoney(summary.approx_house_gross_minor, summary.currency_code)}
</TableCell>
<TableCell>{summary.settlement_batches.length}</TableCell>
</TableRow>
{summary.settlement_batches.map((batch) => (
@@ -1530,7 +1541,9 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
<TableCell>-</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.total_bet_minor, "NPR")}</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.total_payout_minor, "NPR")}</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.approx_house_gross_minor, "NPR")}</TableCell>
<TableCell className={signedProfitCell(item.approx_house_gross_minor, "NPR")}>
{formatPlainMoney(item.approx_house_gross_minor, "NPR")}
</TableCell>
<TableCell>-</TableCell>
<TableCell>-</TableCell>
<TableCell>-</TableCell>
@@ -1548,7 +1561,9 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.total_bet_minor, "NPR")}</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.total_payout_minor, "NPR")}</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.net_win_loss_minor, "NPR")}</TableCell>
<TableCell className={signedProfitCell(item.net_win_loss_minor, "NPR")}>
{formatPlainMoney(item.net_win_loss_minor, "NPR")}
</TableCell>
<TableCell>-</TableCell>
<TableCell>-</TableCell>
<TableCell>-</TableCell>
@@ -1563,7 +1578,9 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
<TableCell>{item.dimension}D</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.total_bet_minor, "NPR")}</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.total_payout_minor, "NPR")}</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.approx_house_gross_minor, "NPR")}</TableCell>
<TableCell className={signedProfitCell(item.approx_house_gross_minor, "NPR")}>
{formatPlainMoney(item.approx_house_gross_minor, "NPR")}
</TableCell>
<TableCell>-</TableCell>
<TableCell>-</TableCell>
<TableCell>-</TableCell>

View File

@@ -3,7 +3,9 @@
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { useTranslation } from "react-i18next";
import { SignedMoney } from "@/lib/admin-signed-money";
import { formatDashboardCreditMajor, formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
import { formatSignedSettlementMoney } from "@/modules/settlement/settlement-signed-money";
import {
Table,
TableBody,
@@ -165,15 +167,19 @@ export function AgentSettlementReportView({
</p>
);
}
const stats = [
{ label: t("settlementReports.platformPnl.billNet", { defaultValue: "平台账单净额" }), value: money(root.platform_bill_net, currencyCode) },
const stats: { label: string; amount: number; signed?: boolean }[] = [
{
label: t("settlementReports.platformPnl.billNet", { defaultValue: "平台账单净额" }),
amount: Number(root.platform_bill_net ?? 0),
},
{
label: t("settlementReports.platformPnl.rounding", { defaultValue: "尾差调整" }),
value: money(root.platform_rounding_adjustment, currencyCode),
amount: Number(root.platform_rounding_adjustment ?? 0),
},
{
label: t("settlementReports.platformPnl.shareProfit", { defaultValue: "占成利润(元数据)" }),
value: money(root.share_profit_meta, currencyCode),
amount: Number(root.share_profit_meta ?? 0),
signed: true,
},
];
return (
@@ -181,7 +187,15 @@ export function AgentSettlementReportView({
{stats.map((item) => (
<div key={item.label} className="rounded-md border border-border/60 px-3 py-2">
<div className="text-xs text-muted-foreground">{item.label}</div>
<div className="mt-1 text-sm font-semibold tabular-nums">{item.value}</div>
<div className="mt-1 text-sm font-semibold tabular-nums">
{item.signed ? (
<SignedMoney amount={item.amount} emphasize>
{formatSignedSettlementMoney(item.amount, currencyCode)}
</SignedMoney>
) : (
money(item.amount, currencyCode)
)}
</div>
</div>
))}
</div>
@@ -190,16 +204,16 @@ export function AgentSettlementReportView({
const items = asRows(root?.items ?? (reportType === "player_win_loss" || reportType === "agent_share" || reportType === "unpaid_bills" || reportType === "overdue" || reportType === "draw_period" ? data : null));
const columnSets: Record<string, { key: string; header: string; money?: boolean }[]> = {
const columnSets: Record<string, { key: string; header: string; money?: boolean; signed?: boolean }[]> = {
player_win_loss: [
{ key: "username", header: t("settlementReports.columns.player", { defaultValue: "玩家" }) },
{ key: "game_type", header: t("settlementReports.columns.gameType", { defaultValue: "玩法" }) },
{ key: "game_win_loss", header: t("settlementReports.columns.grossWinLoss", { defaultValue: "输赢" }), money: true },
{ key: "game_win_loss", header: t("settlementReports.columns.grossWinLoss", { defaultValue: "输赢" }), money: true, signed: true },
{ key: "basic_rebate", header: t("settlementReports.columns.rebate", { defaultValue: "回水" }), money: true },
],
agent_share: [
{ key: "agent_node_id", header: t("settlementReports.columns.agentId", { defaultValue: "代理 ID" }) },
{ key: "game_win_loss", header: t("settlementReports.columns.grossWinLoss", { defaultValue: "输赢" }), money: true },
{ key: "game_win_loss", header: t("settlementReports.columns.grossWinLoss", { defaultValue: "输赢" }), money: true, signed: true },
{ key: "basic_rebate", header: t("settlementReports.columns.rebate", { defaultValue: "回水" }), money: true },
{ key: "entry_count", header: t("settlementReports.columns.count", { defaultValue: "笔数" }) },
],
@@ -216,7 +230,7 @@ export function AgentSettlementReportView({
],
draw_period: [
{ key: "draw_no", header: t("settlementReports.columns.drawNo", { defaultValue: "期号" }) },
{ key: "game_win_loss", header: t("settlementReports.columns.grossWinLoss", { defaultValue: "输赢" }), money: true },
{ key: "game_win_loss", header: t("settlementReports.columns.grossWinLoss", { defaultValue: "输赢" }), money: true, signed: true },
{ key: "basic_rebate", header: t("settlementReports.columns.rebate", { defaultValue: "回水" }), money: true },
{ key: "ticket_count", header: t("settlementReports.columns.count", { defaultValue: "笔数" }) },
],
@@ -238,7 +252,7 @@ function ReportTable({
currencyCode,
}: {
rows: Record<string, unknown>[];
columns: { key: string; header: string; money?: boolean; creditMajor?: boolean }[];
columns: { key: string; header: string; money?: boolean; signed?: boolean; creditMajor?: boolean }[];
currencyCode: string;
}): React.ReactElement {
const { t } = useTranslation("common");
@@ -272,9 +286,15 @@ function ReportTable({
>
{col.creditMajor
? creditMoney(row[col.key], currencyCode)
: col.money
? money(row[col.key], currencyCode)
: String(row[col.key] ?? "—")}
: col.money && col.signed
? (
<SignedMoney amount={Number(row[col.key] ?? 0)} emphasize>
{formatSignedSettlementMoney(Number(row[col.key] ?? 0), currencyCode)}
</SignedMoney>
)
: col.money
? money(row[col.key], currencyCode)
: String(row[col.key] ?? "—")}
</TableCell>
))}
</TableRow>

View File

@@ -47,6 +47,7 @@ import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { formatAdminMinorUnits } from "@/lib/money";
import { signedMoneyClass } from "@/lib/admin-signed-money";
import { cn } from "@/lib/utils";
import { PRD_PAYOUT_MANAGE, PRD_PAYOUT_REVIEW } from "@/lib/admin-prd";
import { useAdminProfile } from "@/stores/admin-session";
@@ -273,12 +274,7 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
</p>
<p>
<span className="text-muted-foreground">{t("platformProfit")}</span>{" "}
<span
className={cn(
"font-mono tabular-nums",
summary.platform_profit < 0 ? "text-destructive" : "text-emerald-700",
)}
>
<span className={cn("font-mono tabular-nums", signedMoneyClass(summary.platform_profit, true))}>
{formatAdminMinorUnits(summary.platform_profit, summary.currency_code ?? "NPR")}
</span>
</p>

View File

@@ -51,6 +51,7 @@ import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/c
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { formatAdminMinorUnits } from "@/lib/money";
import { signedMoneyClass } from "@/lib/admin-signed-money";
import { cn } from "@/lib/utils";
import { PRD_PAYOUT_MANAGE, PRD_PAYOUT_REVIEW } from "@/lib/admin-prd";
import { useAdminProfile } from "@/stores/admin-session";
@@ -270,7 +271,7 @@ export function SettlementBatchesConsole() {
<TableCell
className={cn(
"text-center font-mono text-xs tabular-nums",
row.platform_profit < 0 ? "text-destructive" : "text-emerald-700",
signedMoneyClass(row.platform_profit, true),
)}
>
{formatAdminMinorUnits(row.platform_profit, row.currency_code ?? "NPR")}

View File

@@ -8,9 +8,12 @@ import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { signedMoneyClass } from "@/lib/admin-signed-money";
import { cn } from "@/lib/utils";
import { formatSettlementPeriodSpan } from "@/lib/agent-settlement-period-range";
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
import {
formatSignedSettlementMoney,
} from "@/modules/settlement/settlement-signed-money";
import {
describeBillPaymentDirection,
} from "@/modules/settlement/settlement-bill-display";
@@ -76,26 +79,6 @@ function billTypeTone(row: SettlementBillRow): string {
return "border-border/70 bg-muted/25 text-muted-foreground";
}
function signedMoneyClass(amount: number, emphasize = false): string {
if (amount < 0) {
return cn("text-destructive", emphasize && "font-medium");
}
if (amount > 0) {
return cn("text-emerald-700", emphasize && "font-medium");
}
return "text-muted-foreground";
}
function formatSignedMoney(amount: number, currencyCode: string): string {
if (amount === 0) {
return formatDashboardMoneyMinor(0, currencyCode);
}
const prefix = amount < 0 ? "" : "+";
return `${prefix}${formatDashboardMoneyMinor(Math.abs(amount), currencyCode)}`;
}
function unpaidMoneyClass(row: SettlementBillRow): string {
if (row.unpaid_amount <= 0) {
return "text-muted-foreground";
@@ -253,7 +236,7 @@ export function SettlementBillsTable({
)}
>
{row.gross_win_loss != null ? (
<div>{formatSignedMoney(row.gross_win_loss, currencyCode)}</div>
<div>{formatSignedSettlementMoney(row.gross_win_loss, currencyCode)}</div>
) : (
"—"
)}

View File

@@ -10,6 +10,7 @@ import { toast } from "sonner";
import { getSettlementPeriods, type SettlementPeriodRow } from "@/api/admin-agent-settlement";
import { getAdminIntegrationSites } from "@/api/admin-integration-sites";
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { AdminNoIntegrationSiteState } from "@/components/admin/admin-no-integration-site-state";
import { AgentBillDetail } from "@/modules/settlement/agent-bill-detail";
import { SettlementCenterPeriodDetail } from "@/modules/settlement/settlement-center-period-detail";
import {
@@ -284,7 +285,9 @@ export function SettlementCenterShell(): React.ReactElement {
return (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4">
{siteId === null ? (
{siteId === null && siteOptions.length === 0 && boundAgent === null ? (
<AdminNoIntegrationSiteState canCreate={profile?.is_super_admin === true} />
) : siteId === null ? (
<p className="text-sm text-muted-foreground">{t("empty.noSite", { defaultValue: "请选择站点。" })}</p>
) : !periodsReady ? (
<AdminLoadingState />

View File

@@ -1,17 +1,6 @@
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
import { cn } from "@/lib/utils";
/** 结算金额正负着色:负红、正绿、零灰 */
export function signedSettlementMoneyClass(amount: number, emphasize = false): string {
if (amount < 0) {
return cn("text-destructive", emphasize && "font-medium");
}
if (amount > 0) {
return cn("text-emerald-700 dark:text-emerald-400", emphasize && "font-medium");
}
return "text-muted-foreground";
}
export { signedMoneyClass as signedSettlementMoneyClass } from "@/lib/admin-signed-money";
export function formatSignedSettlementMoney(amount: number, currencyCode: string): string {
if (amount === 0) {