diff --git a/src/api/admin-reports.ts b/src/api/admin-reports.ts index ce83a69..1593370 100644 --- a/src/api/admin-reports.ts +++ b/src/api/admin-reports.ts @@ -7,7 +7,6 @@ import type { AdminReportPlayDimensionRow, AdminReportPlayerWinLossRow, AdminReportQueryParams, - AdminReportRebateCommissionRow, } from "@/types/api/admin-reports"; const A = `/admin`; @@ -35,11 +34,3 @@ export async function getAdminReportPlayDimension( params, }); } - -export async function getAdminReportRebateCommission( - params: AdminReportQueryParams, -): Promise> { - return adminRequest.get>(`${A}/reports/rebate-commission`, { - params, - }); -} diff --git a/src/api/index.ts b/src/api/index.ts index 5768684..fcde8c3 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -21,7 +21,6 @@ export { getAdminReportDailyProfit, getAdminReportPlayDimension, getAdminReportPlayerWinLoss, - getAdminReportRebateCommission, } from "@/api/admin-reports"; export { downloadAdminReportJob, diff --git a/src/app/globals.css b/src/app/globals.css index a8a8232..ac86ec3 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -190,6 +190,15 @@ @apply text-sm text-muted-foreground; } + /* 金额列:允许换行/缩小,避免大额 truncate 或溢出裁切 */ + .admin-money-value { + @apply min-w-0 whitespace-normal break-words [overflow-wrap:anywhere] tabular-nums leading-tight tracking-tight; + } + + .admin-table-shell [data-slot="table-cell"].admin-money-cell { + @apply min-w-[4.5rem] max-w-[11rem] whitespace-normal align-top leading-snug; + } + [data-slot="table-head"], [data-slot="table-cell"] { text-align: center; diff --git a/src/components/admin/admin-money-display.tsx b/src/components/admin/admin-money-display.tsx new file mode 100644 index 0000000..0fbb9a3 --- /dev/null +++ b/src/components/admin/admin-money-display.tsx @@ -0,0 +1,57 @@ +"use client"; + +import type { ElementType, ReactElement, ReactNode } from "react"; + +import { + adminMoneyDisplayClass, + adminMoneyDisplayTitle, + type AdminMoneyDisplaySize, +} from "@/lib/admin-money-display"; +import { cn } from "@/lib/utils"; + +export type AdminMoneyDisplayProps = { + children: ReactNode; + /** 用于自适应字号与 hover 完整值;缺省时从 string children 推断 */ + value?: string | number | null; + size?: AdminMoneyDisplaySize; + emphasize?: boolean; + className?: string; + title?: string; + as?: ElementType; +}; + +/** 卡片/摘要区金额:自适应字号 + 换行,禁止 truncate 裁切 */ +export function AdminMoneyDisplay({ + children, + value, + size = "lg", + emphasize = true, + className, + title, + as: Component = "span", +}: AdminMoneyDisplayProps): ReactElement { + const resolvedValue = + value != null + ? String(value) + : typeof children === "string" || typeof children === "number" + ? String(children) + : ""; + const resolvedTitle = title ?? adminMoneyDisplayTitle(resolvedValue); + + return ( + + {children} + + ); +} diff --git a/src/components/admin/admin-table-money.tsx b/src/components/admin/admin-table-money.tsx new file mode 100644 index 0000000..dbd4c8d --- /dev/null +++ b/src/components/admin/admin-table-money.tsx @@ -0,0 +1,46 @@ +"use client"; + +import type { ReactElement, ReactNode } from "react"; + +import { AdminMoneyDisplay } from "@/components/admin/admin-money-display"; +import type { AdminMoneyDisplaySize } from "@/lib/admin-money-display"; +import { cn } from "@/lib/utils"; + +/** 表格/列表内金额:自适应字号 + 换行,配合 TableCell 的 admin-money-cell */ +export function AdminTableMoney({ + children, + className, + size = "sm", + emphasize = true, +}: { + children: ReactNode; + className?: string; + size?: AdminMoneyDisplaySize; + emphasize?: boolean; +}): ReactElement { + if (typeof children === "string" || typeof children === "number") { + const text = String(children); + return ( + + {text} + + ); + } + + return ( + + {children} + + ); +} + +/** TableCell 金额列常用 className */ +export function adminMoneyCellClassName(className?: string): string { + return cn("admin-money-cell min-w-0 whitespace-normal align-top leading-snug", className); +} diff --git a/src/hooks/use-admin-permission.ts b/src/hooks/use-admin-permission.ts new file mode 100644 index 0000000..27ccca0 --- /dev/null +++ b/src/hooks/use-admin-permission.ts @@ -0,0 +1,44 @@ +import { + adminHasAnyPermission, + adminHasAnyPermissionCode, + adminOperationalPermissionCodes, +} from "@/lib/admin-permissions"; +import { useAdminProfile } from "@/stores/admin-session"; +import type { AdminAccountKind, AdminProfile } from "@/types/api/admin-auth"; + +export function resolveAdminAccountKind(profile: AdminProfile | null | undefined): AdminAccountKind | null { + if (!profile) { + return null; + } + if (profile.account_kind) { + return profile.account_kind === "site_operator" ? "site_admin" : profile.account_kind; + } + if (profile.is_super_admin) { + return "super_admin"; + } + if (profile.agent != null) { + return "agent_operator"; + } + if (profile.site != null) { + return "site_admin"; + } + return "platform_account"; +} + +/** 统一权限与会话身份读取:legacy `prd.*` 与 action code 并存,新代码优先用 `hasAnyCode`。 */ +export function useAdminPermission() { + const profile = useAdminProfile(); + const legacyPermissions = profile?.permissions ?? []; + const operationalPermissions = adminOperationalPermissionCodes(profile); + + return { + profile, + legacyPermissions, + operationalPermissions, + accountKind: resolveAdminAccountKind(profile), + isSuperAdmin: profile?.is_super_admin === true, + /** @deprecated 逐步改用 {@link hasAnyCode} */ + hasAnyLegacy: (required: readonly string[]) => adminHasAnyPermission(legacyPermissions, required), + hasAnyCode: (required: readonly string[]) => adminHasAnyPermissionCode(operationalPermissions, required), + }; +} diff --git a/src/i18n/locales/en/dashboard.json b/src/i18n/locales/en/dashboard.json index dff0128..662160d 100644 --- a/src/i18n/locales/en/dashboard.json +++ b/src/i18n/locales/en/dashboard.json @@ -190,6 +190,49 @@ "bills": "Settlement" } }, + "finance": { + "title": "Finance workspace", + "subtitle": "{{name}} · reconcile & settlement", + "subtitleFallback": "Site finance · reconcile & settlement", + "abnormalTransfers": "Abnormal transfers", + "pendingConfirmBills": "Bills pending confirm", + "pendingConfirmHint": "Awaiting finance confirmation after period close", + "payableBills": "Bills pending payout", + "payableUnpaid": "Unpaid {{amount}}", + "payableUnpaidLabel": "Total unpaid", + "walletPlayers": "Wallet players", + "creditPlayersHint": "{{count}} credit players", + "settlementTitle": "Credit settlement", + "reconcileTitle": "Wallet reconcile", + "overviewEmpty": "No finance summary. Confirm the integration site is bound.", + "quickLinks": { + "reconcile": "Reconcile", + "transfers": "Transfer orders", + "bills": "Settlement center", + "reports": "Reports" + } + }, + "cs": { + "title": "Support workspace", + "subtitle": "{{name}} · player & ticket lookup", + "subtitleFallback": "Site support · player & ticket lookup", + "playerCount": "Site players", + "playerCountHint": "Registered players on this site", + "ticketsToday": "Tickets today", + "activePlayersToday": "Active players today", + "activePlayersHint": "Players with bets today", + "latestTicketAt": "Latest ticket {{time}}", + "noTicketToday": "No tickets yet today", + "workspaceTitle": "Quick access", + "scopeTitle": "Today at a glance", + "openModule": "Open module", + "overviewEmpty": "No support summary. Confirm the integration site is bound.", + "quickLinks": { + "players": "Players", + "tickets": "Tickets", + "wallet": "Wallet ledger" + } + }, "agent": { "title": "Operations overview", "subtitle": "{{name}} · your line", diff --git a/src/i18n/locales/en/reports.json b/src/i18n/locales/en/reports.json index a262e3b..6e27372 100644 --- a/src/i18n/locales/en/reports.json +++ b/src/i18n/locales/en/reports.json @@ -62,7 +62,6 @@ "hot_number_risk_report": "Hot number risk", "play_dimension_report": "Play dimension", "sold_out_number_report": "Sold-out numbers", - "rebate_commission_report": "Rebate / commission", "audit_operation_report": "Admin audit" }, "empty": "No matching reports", @@ -173,16 +172,6 @@ "extra": "Usage", "time": "Version" }, - "rebateCommission": { - "primary": "Play", - "secondary": "Orders", - "metricA": "Rebate", - "metricB": "Ticket items", - "metricC": "Commission", - "status": "Rule hit", - "extra": "Note", - "time": "Time" - }, "adminAudit": { "primary": "Log ID", "secondary": "Operator type", @@ -303,10 +292,6 @@ "title": "Sold-out number report", "summary": "Review sold-out numbers, sold-out time, and risk lock state by draw." }, - "rebate_commission": { - "title": "Commission / rebate report", - "summary": "Wallet-mode instant rebate by business date; defaults to the last 30 days. Not credit-line period settlement." - }, "admin_audit": { "title": "Admin operation audit report", "summary": "Admin actions by operator and record time; defaults to the last 30 days." diff --git a/src/i18n/locales/ne/dashboard.json b/src/i18n/locales/ne/dashboard.json index 6cd5a7a..36bcd5a 100644 --- a/src/i18n/locales/ne/dashboard.json +++ b/src/i18n/locales/ne/dashboard.json @@ -187,6 +187,49 @@ "bills": "सेटलमेन्ट" } }, + "finance": { + "title": "वित्त कार्यस्थल", + "subtitle": "{{name}} · मिलान र सेटलमेन्ट", + "subtitleFallback": "साइट वित्त · मिलान र सेटलमेन्ट", + "abnormalTransfers": "असामान्य स्थानान्तरण", + "pendingConfirmBills": "पुष्टि बाँकी बिल", + "pendingConfirmHint": "अवधि बन्द पछि वित्त पुष्टि बाँकी", + "payableBills": "भुक्तानी बाँकी बिल", + "payableUnpaid": "नतिरेको {{amount}}", + "payableUnpaidLabel": "कुल बाँकी", + "walletPlayers": "वालेट खेलाडी", + "creditPlayersHint": "क्रेडिट {{count}} जना", + "settlementTitle": "क्रेडिट सेटलमेन्ट", + "reconcileTitle": "वालेट मिलान", + "overviewEmpty": "वित्त सारांश छैन। साइट बाइन्डिङ जाँच गर्नुहोस्।", + "quickLinks": { + "reconcile": "मिलान केन्द्र", + "transfers": "स्थानान्तरण", + "bills": "सेटलमेन्ट केन्द्र", + "reports": "रिपोर्ट" + } + }, + "cs": { + "title": "सपोर्ट कार्यस्थल", + "subtitle": "{{name}} · खेलाडी र टिकट", + "subtitleFallback": "साइट सपोर्ट · खेलाडी र टिकट", + "playerCount": "साइट खेलाडी", + "playerCountHint": "यो साइटका दर्ता खेलाडी", + "ticketsToday": "आजका टिकट", + "activePlayersToday": "आज सक्रिय खेलाडी", + "activePlayersHint": "आज बाजी गर्ने खेलाडी", + "latestTicketAt": "पछिल्लो टिकट {{time}}", + "noTicketToday": "आज टिकट छैन", + "workspaceTitle": "छिटो पहुँच", + "scopeTitle": "आजको झलक", + "openModule": "मोड्युल खोल्नुहोस्", + "overviewEmpty": "सपोर्ट सारांश छैन। साइट बाइन्डिङ जाँच गर्नुहोस्।", + "quickLinks": { + "players": "खेलाडी", + "tickets": "टिकट", + "wallet": "वालेट लेजर" + } + }, "agent": { "title": "सञ्चालन सारांश", "subtitle": "{{name}} · यो लाइन", diff --git a/src/i18n/locales/ne/reports.json b/src/i18n/locales/ne/reports.json index a7164e4..3270596 100644 --- a/src/i18n/locales/ne/reports.json +++ b/src/i18n/locales/ne/reports.json @@ -61,7 +61,6 @@ "hot_number_risk_report": "लोकप्रिय नम्बर जोखिम", "play_dimension_report": "प्ले आयाम", "sold_out_number_report": "बिक्री समाप्त नम्बर", - "rebate_commission_report": "रिबेट / कमिसन", "audit_operation_report": "प्रशासक अडिट" }, "empty": "मिल्ने रिपोर्ट छैन", @@ -172,16 +171,6 @@ "extra": "प्रयोग", "time": "संस्करण" }, - "rebateCommission": { - "primary": "खेल", - "secondary": "अर्डर", - "metricA": "रिबेट", - "metricB": "टिकट आइटम", - "metricC": "कमिसन", - "status": "नियम मिलान", - "extra": "टिप्पणी", - "time": "समय" - }, "adminAudit": { "primary": "लग ID", "secondary": "अपरेटर प्रकार", @@ -300,10 +289,6 @@ "title": "सोल्ड-आउट नम्बर रिपोर्ट", "summary": "ड्र अनुसार सोल्ड-आउट नम्बर, समय र जोखिम लक अवस्था हेर्नुहोस्।" }, - "rebate_commission": { - "title": "कमिसन / रिबेट रिपोर्ट", - "summary": "वालेट-मोड तत्काल रिबेट, व्यावसायिक मितिअनुसार; मिति नचयेमा पछिल्लो ३० दिन।" - }, "admin_audit": { "title": "एडमिन अपरेशन अडिट रिपोर्ट", "summary": "अपरेटर र रेकर्ड समय अनुसार; मिति नचयेमा पछिल्लो ३० दिन।" diff --git a/src/i18n/locales/zh/adminUsers.json b/src/i18n/locales/zh/adminUsers.json index 329068d..1323317 100644 --- a/src/i18n/locales/zh/adminUsers.json +++ b/src/i18n/locales/zh/adminUsers.json @@ -16,7 +16,7 @@ "deleteSuccess": "已删除 {{name}}", "deleteFailed": "删除失败", "roleListTitle": "平台角色管理", - "roleListHint": "可新增自定义角色并配置权限;内置角色(超级管理员、站点管理员、代理)不可删除。", + "roleListHint": "可新增自定义角色并配置权限;内置角色(超级管理员、站点管理员、站点财务、站点客服、代理)不可删除。", "createRole": "新增平台角色", "roleCreateSuccess": "已创建角色 {{name}}", "roleUpdateSuccess": "已更新角色 {{name}}", diff --git a/src/i18n/locales/zh/dashboard.json b/src/i18n/locales/zh/dashboard.json index adf1a12..3541350 100644 --- a/src/i18n/locales/zh/dashboard.json +++ b/src/i18n/locales/zh/dashboard.json @@ -190,6 +190,49 @@ "bills": "结算中心" } }, + "finance": { + "title": "财务工作台", + "subtitle": "{{name}} · 对账与结算", + "subtitleFallback": "站点财务 · 对账与结算", + "abnormalTransfers": "异常转账单", + "pendingConfirmBills": "待确认账单", + "pendingConfirmHint": "账期关账后待财务确认", + "payableBills": "待收付账单", + "payableUnpaid": "未收付 {{amount}}", + "payableUnpaidLabel": "待收付合计", + "walletPlayers": "钱包盘玩家", + "creditPlayersHint": "信用盘 {{count}} 人", + "settlementTitle": "信用结算", + "reconcileTitle": "钱包对账", + "overviewEmpty": "暂无财务摘要,请确认已绑定接入站点。", + "quickLinks": { + "reconcile": "对账中心", + "transfers": "转账单", + "bills": "结算中心", + "reports": "报表中心" + } + }, + "cs": { + "title": "客服工作台", + "subtitle": "{{name}} · 玩家与注单查询", + "subtitleFallback": "站点客服 · 玩家与注单查询", + "playerCount": "站点玩家", + "playerCountHint": "本站点注册玩家总数", + "ticketsToday": "今日注单", + "activePlayersToday": "今日活跃玩家", + "activePlayersHint": "今日有下注的玩家数", + "latestTicketAt": "最近注单 {{time}}", + "noTicketToday": "今日暂无注单", + "workspaceTitle": "常用入口", + "scopeTitle": "今日概况", + "openModule": "进入模块", + "overviewEmpty": "暂无客服摘要,请确认已绑定接入站点。", + "quickLinks": { + "players": "玩家查询", + "tickets": "注单查询", + "wallet": "钱包流水" + } + }, "agent": { "title": "经营概览", "subtitle": "{{name}} · 本线路", diff --git a/src/i18n/locales/zh/reports.json b/src/i18n/locales/zh/reports.json index fae9a92..e1a9f2c 100644 --- a/src/i18n/locales/zh/reports.json +++ b/src/i18n/locales/zh/reports.json @@ -62,7 +62,6 @@ "hot_number_risk_report": "热门号码风险", "play_dimension_report": "玩法维度", "sold_out_number_report": "售罄号码", - "rebate_commission_report": "佣金/回水", "audit_operation_report": "后台操作审计" }, "empty": "没有匹配的报表", @@ -173,16 +172,6 @@ "extra": "使用率", "time": "版本" }, - "rebateCommission": { - "primary": "玩法", - "secondary": "订单数", - "metricA": "回水", - "metricB": "注单数", - "metricC": "佣金", - "status": "配置命中", - "extra": "备注", - "time": "时间" - }, "adminAudit": { "primary": "日志 ID", "secondary": "操作者类型", @@ -303,10 +292,6 @@ "title": "售罄号码报表", "summary": "查看单期已售罄号码、售罄时间和风险封锁情况。" }, - "rebate_commission": { - "title": "佣金/回水报表", - "summary": "钱包盘下注立减回水,按业务日汇总;未选日期默认近 30 天。非信用占成账期。" - }, "admin_audit": { "title": "后台操作审计报表", "summary": "按操作人与记录创建时间筛选;未选日期默认近 30 天。" diff --git a/src/lib/admin-money-display.ts b/src/lib/admin-money-display.ts new file mode 100644 index 0000000..2f2e558 --- /dev/null +++ b/src/lib/admin-money-display.ts @@ -0,0 +1,65 @@ +import { cn } from "@/lib/utils"; + +export type AdminMoneyDisplaySize = "sm" | "md" | "lg" | "xl"; + +const SIZE_TIERS: Record = { + sm: ["text-sm", "text-xs", "text-[11px]", "text-[10px]", "text-[9px]"], + md: ["text-base", "text-sm", "text-xs", "text-[11px]", "text-[10px]"], + lg: ["text-lg sm:text-xl", "text-base sm:text-lg", "text-sm sm:text-base", "text-xs sm:text-sm", "text-[11px] sm:text-xs"], + xl: ["text-2xl", "text-xl", "text-lg", "text-base sm:text-lg", "text-sm sm:text-base"], +}; + +function tierForLength(len: number): number { + if (len > 20) { + return 4; + } + if (len > 16) { + return 3; + } + if (len > 12) { + return 2; + } + if (len > 8) { + return 1; + } + + return 0; +} + +/** 按字符长度分档缩小字号,避免卡片/栅格/表格内大额被裁切 */ +export function adminMoneyDisplaySizeClass( + value: string, + size: AdminMoneyDisplaySize = "lg", +): string { + const len = value.replace(/\s/g, "").length; + const tier = tierForLength(len); + return SIZE_TIERS[size][tier] ?? SIZE_TIERS[size][0]; +} + +export function adminMoneyDisplayClass( + value: string, + { + size = "lg", + emphasize = true, + className, + }: { + size?: AdminMoneyDisplaySize; + emphasize?: boolean; + className?: string; + } = {}, +): string { + return cn( + "min-w-0 whitespace-normal break-words [overflow-wrap:anywhere] tabular-nums leading-tight tracking-tight", + emphasize ? "font-semibold" : "font-medium", + adminMoneyDisplaySizeClass(value, size), + className, + ); +} + +export function adminMoneyDisplayTitle(value: string | number | null | undefined): string | undefined { + if (value == null) { + return undefined; + } + const text = String(value).trim(); + return text === "" || text === "…" || text === "—" ? undefined : text; +} diff --git a/src/lib/admin-permission-codes.ts b/src/lib/admin-permission-codes.ts new file mode 100644 index 0000000..a5170d7 --- /dev/null +++ b/src/lib/admin-permission-codes.ts @@ -0,0 +1,19 @@ +/** 与 Laravel `admin_menu_actions.permission_code` / API 鉴权对齐 */ + +export const PERM_DASHBOARD_VIEW = "dashboard.view" as const; +export const PERM_SERVICE_REPORT_VIEW = "service.report.view" as const; +export const PERM_SERVICE_REPORT_EXPORT = "service.report.export" as const; +export const PERM_SERVICE_PLAYERS_VIEW = "service.players.view" as const; +export const PERM_SERVICE_PLAYERS_MANAGE = "service.players.manage" as const; +export const PERM_SERVICE_PLAYERS_FREEZE = "service.players.freeze" as const; +export const PERM_SERVICE_TICKETS_VIEW = "service.tickets.view" as const; +export const PERM_SERVICE_WALLET_VIEW = "service.wallet.view" as const; +export const PERM_SERVICE_WALLET_MANAGE = "service.wallet.manage" as const; +export const PERM_SERVICE_WALLET_ADJUST = "service.wallet.adjust" as const; +export const PERM_SERVICE_RECONCILE_VIEW = "service.reconcile.view" as const; +export const PERM_SERVICE_RECONCILE_MANAGE = "service.reconcile.manage" as const; +export const PERM_SERVICE_AUDIT_VIEW = "service.audit.view" as const; +export const PERM_AGENT_NODE_VIEW = "agent.node.view" as const; +export const PERM_AGENT_NODE_MANAGE = "agent.node.manage" as const; +export const PERM_SETTLEMENT_AGENT_VIEW = "settlement.agent.view" as const; +export const PERM_SETTLEMENT_AGENT_MANAGE = "settlement.agent.manage" as const; diff --git a/src/lib/admin-permissions.ts b/src/lib/admin-permissions.ts index 7e85f8c..e3f2be1 100644 --- a/src/lib/admin-permissions.ts +++ b/src/lib/admin-permissions.ts @@ -9,3 +9,25 @@ export function adminHasAnyPermission( } return required.some((slug) => set.includes(slug)); } + +/** 当前登录管理员是否拥有 `required` 中任一 action code(与 API 鉴权 / `operational_permissions` 对齐)。 */ +export function adminHasAnyPermissionCode( + permissionCodes: readonly string[] | null | undefined, + required: readonly string[], +): boolean { + const set = permissionCodes ?? []; + if (set.length === 0 || required.length === 0) { + return false; + } + return required.some((code) => set.includes(code)); +} + +/** 读取会话中的 operational_permissions;旧缓存无该字段时回退到 permissions(兼容发版前 localStorage)。 */ +export function adminOperationalPermissionCodes( + profile: { operational_permissions?: string[]; permissions?: string[] } | null | undefined, +): readonly string[] { + if (Array.isArray(profile?.operational_permissions) && profile.operational_permissions.length > 0) { + return profile.operational_permissions; + } + return profile?.permissions ?? []; +} diff --git a/src/lib/admin-session-variants.ts b/src/lib/admin-session-variants.ts index 93bf31d..115bf61 100644 --- a/src/lib/admin-session-variants.ts +++ b/src/lib/admin-session-variants.ts @@ -5,7 +5,29 @@ export function isAgentOperator(profile: AdminProfile | null | undefined): boole return profile?.agent != null && profile.is_super_admin !== true; } -/** 平台站点管理员(绑定 site_admin 角色、无代理节点)。 */ -export function isSiteAdminOperator(profile: AdminProfile | null | undefined): boolean { - return profile?.site != null && profile.is_super_admin !== true; +/** 任意接入站点平台账号(site_admin / site_finance / site_cs)。 */ +export function isSiteOperator(profile: AdminProfile | null | undefined): boolean { + if (profile?.is_super_admin === true || profile?.agent != null) { + return false; + } + const kind = profile?.account_kind; + return kind === "site_admin" || kind === "site_finance" || kind === "site_cs" || profile?.site != null; +} + +/** 站点主运营(满配 site_admin);代理页站点方门控仅对此角色放宽。 */ +export function isSiteAdminOperator(profile: AdminProfile | null | undefined): boolean { + return profile?.account_kind === "site_admin" || ( + profile?.site != null + && profile?.account_kind == null + && profile?.is_super_admin !== true + && profile?.agent == null + ); +} + +export function isSiteFinanceOperator(profile: AdminProfile | null | undefined): boolean { + return profile?.account_kind === "site_finance"; +} + +export function isSiteCsOperator(profile: AdminProfile | null | undefined): boolean { + return profile?.account_kind === "site_cs"; } diff --git a/src/lib/admin-signed-money.tsx b/src/lib/admin-signed-money.tsx index 1498b2f..d9cd6ef 100644 --- a/src/lib/admin-signed-money.tsx +++ b/src/lib/admin-signed-money.tsx @@ -2,6 +2,7 @@ import type { ReactElement, ReactNode } from "react"; +import { AdminMoneyDisplay } from "@/components/admin/admin-money-display"; import { cn } from "@/lib/utils"; /** 盈亏 / 输赢:负红、正绿、零灰 */ @@ -49,8 +50,25 @@ export function SignedMoney({ emphasize?: boolean; className?: string; }): ReactElement { + const colorClass = signedMoneyClass(amount, emphasize); + + if (typeof children === "string" || typeof children === "number") { + const text = String(children); + return ( + + {text} + + ); + } + return ( - + {children} ); diff --git a/src/lib/platform-system-roles.ts b/src/lib/platform-system-roles.ts index 87171e9..b280de7 100644 --- a/src/lib/platform-system-roles.ts +++ b/src/lib/platform-system-roles.ts @@ -2,12 +2,16 @@ import type { AdminRoleRow } from "@/types/api/index"; export const PLATFORM_SUPER_ADMIN_SLUG = "super_admin"; export const PLATFORM_SITE_ADMIN_SLUG = "site_admin"; +export const PLATFORM_SITE_FINANCE_SLUG = "site_finance"; +export const PLATFORM_SITE_CS_SLUG = "site_cs"; export const PLATFORM_AGENT_SLUG = "agent"; export function isPlatformFixedRole(role: Pick): boolean { return ( role.slug === PLATFORM_SUPER_ADMIN_SLUG || role.slug === PLATFORM_SITE_ADMIN_SLUG + || role.slug === PLATFORM_SITE_FINANCE_SLUG + || role.slug === PLATFORM_SITE_CS_SLUG || role.slug === PLATFORM_AGENT_SLUG ); } diff --git a/src/lib/report-export-map.ts b/src/lib/report-export-map.ts index cdf3ecd..57e25ae 100644 --- a/src/lib/report-export-map.ts +++ b/src/lib/report-export-map.ts @@ -18,7 +18,6 @@ export type ReportUiKey = | "hot_number_risk" | "play_dimension" | "sold_out_number" - | "rebate_commission" | "admin_audit"; /** Maps UI keys to POST /admin/report-jobs `report_type` */ @@ -30,7 +29,6 @@ export const REPORT_UI_TO_JOB_TYPE: Record = { hot_number_risk: "hot_number_risk_report", play_dimension: "play_dimension_report", sold_out_number: "sold_out_number_report", - rebate_commission: "rebate_commission_report", admin_audit: "audit_operation_report", }; diff --git a/src/modules/agents/agent-line-detail-panel.tsx b/src/modules/agents/agent-line-detail-panel.tsx index e0a82c8..2b80851 100644 --- a/src/modules/agents/agent-line-detail-panel.tsx +++ b/src/modules/agents/agent-line-detail-panel.tsx @@ -24,6 +24,8 @@ import { Button } from "@/components/ui/button"; import { percentValueToUi } from "@/lib/admin-rate-percent"; import { isLineRootAgentNode } from "@/lib/agent-profile-caps"; import { resolveRoleStatusTone } from "@/lib/admin-status-tone"; +import { AdminMoneyDisplay } from "@/components/admin/admin-money-display"; +import { AdminTableMoney, adminMoneyCellClassName } from "@/components/admin/admin-table-money"; import { cn } from "@/lib/utils"; import type { AgentNodeRow, AgentProfileRow } from "@/types/api/admin-agent"; @@ -369,10 +371,11 @@ function OverviewTab({

) : null} -
+
-
+
) : "—"} - - {summary ? formatCredit(summary.credit_limit) : "—"} + + {summary ? {formatCredit(summary.credit_limit)} : "—"} - - {summary ? formatCredit(summary.allocated_credit) : "—"} + + {summary ? {formatCredit(summary.allocated_credit)} : "—"} {childCountById.get(child.id) ?? 0} @@ -625,31 +631,45 @@ function MetricCard({ subtitle, accent = false, highlight = false, + money = true, }: { label: string; value: string; subtitle?: string; accent?: boolean; highlight?: boolean; + /** 金额类指标:自适应字号 + 换行 */ + money?: boolean; }): React.ReactElement { return (

{label}

-

- {value} -

+ {money ? ( + + {value} + + ) : ( +

+ {value} +

+ )} {subtitle ?

{subtitle}

: null}
); diff --git a/src/modules/agents/agent-profile-fields.tsx b/src/modules/agents/agent-profile-fields.tsx index ea82284..cae9e2b 100644 --- a/src/modules/agents/agent-profile-fields.tsx +++ b/src/modules/agents/agent-profile-fields.tsx @@ -19,6 +19,7 @@ import type { AgentParentCaps } from "@/types/api/admin-agent"; import { Info } from "lucide-react"; import { AdminNumericStepper } from "@/components/admin/admin-numeric-stepper"; +import { AdminMoneyDisplay } from "@/components/admin/admin-money-display"; import { AdminStatusBadge } from "@/components/admin/admin-status-badge"; import { AGENT_PERCENT_HARD_MAX, @@ -411,14 +412,14 @@ function ReadOnlyScalar({
- + {value} {suffix ? {suffix} : null} - +
); } diff --git a/src/modules/dashboard/agent-dashboard-console.tsx b/src/modules/dashboard/agent-dashboard-console.tsx index cdac8d2..2595a6e 100644 --- a/src/modules/dashboard/agent-dashboard-console.tsx +++ b/src/modules/dashboard/agent-dashboard-console.tsx @@ -17,6 +17,7 @@ import { adminWeekdayKeyForDate, formatAdminBusinessDateIso, formatAdminCalendar import { cn } from "@/lib/utils"; import { useAdminProfile } from "@/stores/admin-session"; import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state"; +import { AdminMoneyDisplay } from "@/components/admin/admin-money-display"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -207,9 +208,14 @@ export function AgentDashboardConsole(): ReactElement {
-

+ {formatDashboardCreditMajor(overview.credit_limit, displayCurrency)} -

+

{t("agent.creditAvailable", { amount: formatDashboardCreditMajor(overview.available_credit, displayCurrency), diff --git a/src/modules/dashboard/dashboard-page-client.tsx b/src/modules/dashboard/dashboard-page-client.tsx index 1c17384..7b164cb 100644 --- a/src/modules/dashboard/dashboard-page-client.tsx +++ b/src/modules/dashboard/dashboard-page-client.tsx @@ -2,13 +2,15 @@ import type { ReactElement } from "react"; -import { isAgentOperator, isSiteAdminOperator } from "@/lib/admin-session-variants"; +import { isAgentOperator, isSiteFinanceOperator, isSiteCsOperator, isSiteOperator } from "@/lib/admin-session-variants"; import { AgentDashboardConsole } from "@/modules/dashboard/agent-dashboard-console"; import { DashboardConsole } from "@/modules/dashboard/dashboard-console"; +import { SiteCsDashboardConsole } from "@/modules/dashboard/site-cs-dashboard-console"; +import { SiteFinanceDashboardConsole } from "@/modules/dashboard/site-finance-dashboard-console"; import { SiteDashboardConsole } from "@/modules/dashboard/site-dashboard-console"; import { useAdminProfile } from "@/stores/admin-session"; -/** 超管/平台账号走全站仪表盘;站点管理员走站点仪表盘;代理经营账号走代理仪表盘。 */ +/** 超管/平台账号走全站仪表盘;站点运营账号走站点仪表盘;代理经营账号走代理仪表盘。 */ export function DashboardPageClient(): ReactElement { const profile = useAdminProfile(); @@ -16,7 +18,15 @@ export function DashboardPageClient(): ReactElement { return ; } - if (isSiteAdminOperator(profile)) { + if (isSiteFinanceOperator(profile)) { + return ; + } + + if (isSiteCsOperator(profile)) { + return ; + } + + if (isSiteOperator(profile)) { return ; } diff --git a/src/modules/dashboard/dashboard-visuals.tsx b/src/modules/dashboard/dashboard-visuals.tsx index 41ce997..14f435a 100644 --- a/src/modules/dashboard/dashboard-visuals.tsx +++ b/src/modules/dashboard/dashboard-visuals.tsx @@ -21,6 +21,7 @@ import { import { Card, CardContent } from "@/components/ui/card"; import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state"; +import { AdminMoneyDisplay } from "@/components/admin/admin-money-display"; import { Skeleton } from "@/components/ui/skeleton"; import { ChartContainer, @@ -37,6 +38,7 @@ import { } from "@/lib/money"; import { cn } from "@/lib/utils"; import { SignedMoney, signedMoneyClass } from "@/lib/admin-signed-money"; +import { adminMoneyDisplayClass } from "@/lib/admin-money-display"; import { buildBatchProgressConfig, buildFinanceStructureConfig, @@ -68,7 +70,7 @@ type DashboardFinanceMetricCell = { emphasize: boolean; }; -/** KPI 卡片底部三列:仅数字(币种见卡片主值),过长时省略号 + hover 看全称 */ +/** KPI 卡片底部三列:仅数字(币种见卡片主值),过长时缩小字号 + 换行 */ function formatDashboardMetricAmount( minor: number, currencyCode: string | null, @@ -115,7 +117,8 @@ function DashboardFinanceMetricCells({

{label}

-

+ {value} -

+
); } @@ -303,8 +311,11 @@ export function DashboardKpiCard({

{resolvedValue} @@ -418,9 +429,21 @@ export function StatCard({

{label}

-

- {value} -

+ {typeof value === "string" || typeof value === "number" ? ( + + {value} + + ) : ( +

+ {value} +

+ )} {deltaLabel ? (

{deltaLabel}

) : null} @@ -557,6 +580,16 @@ export function DashboardPanelCard({

{title}

{loading ? ( + ) : typeof value === "string" || typeof value === "number" ? ( + + {value} + ) : (

{value} diff --git a/src/modules/dashboard/site-cs-dashboard-console.tsx b/src/modules/dashboard/site-cs-dashboard-console.tsx new file mode 100644 index 0000000..ece4aad --- /dev/null +++ b/src/modules/dashboard/site-cs-dashboard-console.tsx @@ -0,0 +1,196 @@ +"use client"; + +import Link from "next/link"; +import { useCallback, useMemo, useState, type ReactElement } from "react"; +import { useTranslation } from "react-i18next"; +import { ClipboardList, RefreshCw, Search, Users } 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 { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state"; +import { + DashboardKpiCard, + DashboardScopeMetric, +} from "@/modules/dashboard/dashboard-visuals"; +import { cn } from "@/lib/utils"; +import { useAdminProfile } from "@/stores/admin-session"; +import type { + AdminDashboardSiteCsOverview, + AdminDashboardWarning, +} from "@/types/api/admin-dashboard"; +import { LotteryApiBizError } from "@/types/api/errors"; + +export function SiteCsDashboardConsole(): ReactElement { + const { t } = useTranslation(["dashboard", "common"]); + const tRef = useTranslationRef(["dashboard", "common"]); + const formatDt = useAdminDateTimeFormatter(); + const profile = useAdminProfile(); + const site = profile?.site ?? null; + + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(null); + const [apiWarnings, setApiWarnings] = useState([]); + const [overview, setOverview] = useState(null); + + const load = useCallback(async (isRefresh = false) => { + if (isRefresh) { + setRefreshing(true); + } else { + setLoading(true); + } + setError(null); + + try { + const d = await getAdminDashboard(); + setOverview(d.site_cs_overview); + setApiWarnings(d.warnings ?? []); + } 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 activityHint = useMemo(() => { + if (!overview) { + return ""; + } + if (overview.latest_ticket_at) { + return t("cs.latestTicketAt", { time: formatDt(overview.latest_ticket_at) }); + } + return t("cs.noTicketToday"); + }, [formatDt, overview, t]); + + const quickLinks = useMemo( + () => [ + { href: "/admin/players", label: t("cs.quickLinks.players"), icon: Users }, + { href: "/admin/tickets", label: t("cs.quickLinks.tickets"), icon: ClipboardList }, + { href: "/admin/wallet/transactions", label: t("cs.quickLinks.wallet"), icon: Search }, + ], + [t], + ); + + return ( +

+
+
+

{t("cs.title")}

+

+ {site + ? t("cs.subtitle", { name: site.name || site.code }) + : t("cs.subtitleFallback")} +

+
+ +
+ + {error ? ( + + {t("notice")} + {error} + + ) : null} + + {!loading && apiWarnings.length > 0 ? ( + + {t("notice")} + {apiWarnings.map((w) => w.message).join(" ")} + + ) : null} + + {loading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ) : overview ? ( +
+
+ } + hint={t("cs.playerCountHint")} + /> + } + hint={activityHint} + /> + } + hint={t("cs.activePlayersHint")} + /> +
+ + + + {t("cs.workspaceTitle")} + + + {quickLinks.map((link) => { + const Icon = link.icon; + return ( + + + {link.label} + {t("cs.openModule")} + + ); + })} + + + + + + {t("cs.scopeTitle")} + + + + + + +
+ ) : ( + + {t("cs.overviewEmpty")} + + )} +
+ ); +} diff --git a/src/modules/dashboard/site-finance-dashboard-console.tsx b/src/modules/dashboard/site-finance-dashboard-console.tsx new file mode 100644 index 0000000..455630c --- /dev/null +++ b/src/modules/dashboard/site-finance-dashboard-console.tsx @@ -0,0 +1,244 @@ +"use client"; + +import Link from "next/link"; +import { useCallback, useMemo, useState, type ReactElement } from "react"; +import { useTranslation } from "react-i18next"; +import { AlertTriangle, ClipboardList, RefreshCw, Scale, Users, Wallet } from "lucide-react"; + +import { getAdminDashboard } from "@/api/admin-dashboard"; +import { useAsyncEffect } from "@/hooks/use-async-effect"; +import { useTranslationRef } from "@/hooks/use-translation-ref"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state"; +import { DashboardCurrentDrawCard } from "@/modules/dashboard/dashboard-current-draw-card"; +import { + AbnormalTransferPanelFooter, + DashboardKpiCard, + DashboardScopeMetric, + DashboardStatRow, +} from "@/modules/dashboard/dashboard-visuals"; +import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics"; +import { cn } from "@/lib/utils"; +import { useAdminProfile } from "@/stores/admin-session"; +import type { + AdminDashboardSiteFinanceOverview, + AdminDashboardWarning, +} from "@/types/api/admin-dashboard"; +import type { DrawCurrentSnapshot } from "@/types/api/public-draw"; +import { LotteryApiBizError } from "@/types/api/errors"; + +export function SiteFinanceDashboardConsole(): ReactElement { + const { t } = useTranslation(["dashboard", "common"]); + const tRef = useTranslationRef(["dashboard", "common"]); + const profile = useAdminProfile(); + const site = profile?.site ?? null; + + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(null); + const [apiWarnings, setApiWarnings] = useState([]); + const [hall, setHall] = useState(null); + const [drawId, setDrawId] = useState(null); + const [overview, setOverview] = useState(null); + const [walletPermission, setWalletPermission] = useState(false); + + 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_finance_overview); + setApiWarnings(d.warnings ?? []); + setWalletPermission(d.capabilities?.wallet_transfer_view ?? false); + 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"; + const abnormalCount = overview?.abnormal_transfer_count ?? null; + + const quickLinks = useMemo( + () => [ + { href: "/admin/reconcile", label: t("finance.quickLinks.reconcile") }, + { href: "/admin/wallet/transfer-orders", label: t("finance.quickLinks.transfers") }, + { href: "/admin/settlement-center", label: t("finance.quickLinks.bills") }, + { href: "/admin/reports", label: t("finance.quickLinks.reports") }, + ], + [t], + ); + + return ( +
+
+
+

{t("finance.title")}

+

+ {site + ? t("finance.subtitle", { name: site.name || site.code }) + : t("finance.subtitleFallback")} +

+
+ +
+ + {error ? ( + + {t("notice")} + {error} + + ) : null} + + {!loading && apiWarnings.length > 0 ? ( + + {t("notice")} + {apiWarnings.map((w) => w.message).join(" ")} + + ) : null} + + {loading ? ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ ) : overview ? ( +
+
+ } + hint={t("abnormalTransferScope")} + accent={(abnormalCount ?? 0) > 0 ? "destructive" : "muted"} + /> + } + hint={t("finance.pendingConfirmHint")} + accent={overview.pending_confirm_bill_count > 0 ? "primary" : "muted"} + /> + } + hint={t("finance.payableUnpaid", { + amount: formatDashboardMoneyMinor(overview.payable_unpaid_minor, displayCurrency), + })} + accent={overview.payable_bill_count > 0 ? "destructive" : "muted"} + /> + } + hint={t("finance.creditPlayersHint", { count: overview.credit_player_count })} + /> +
+ +
+ + + {t("finance.settlementTitle")} + + + + + + + + + + + {t("finance.reconcileTitle")} + + + + + + {t("viewTransferOrders")} + + + +
+ + + + {t("quickLinksTitle")} + + + {quickLinks.map((link) => ( + + {link.label} + + ))} + + +
+ ) : ( + + {t("finance.overviewEmpty")} + + )} + + +
+ ); +} diff --git a/src/modules/draws/draws-index-console.tsx b/src/modules/draws/draws-index-console.tsx index afdb107..de50cd9 100644 --- a/src/modules/draws/draws-index-console.tsx +++ b/src/modules/draws/draws-index-console.tsx @@ -49,6 +49,7 @@ import { canDeleteDrawRow, canEditDrawRow, } from "./draw-list-actions"; +import { AdminTableMoney, adminMoneyCellClassName } from "@/components/admin/admin-table-money"; import { formatAdminMinorUnits } from "@/lib/money"; import { useConfirmAction } from "@/hooks/use-confirm-action"; import { useExportLabels } from "@/hooks/use-export-labels"; @@ -429,25 +430,30 @@ export function DrawsIndexConsole() { {canViewFinance ? ( <> - - {row.total_bet_minor != null - ? formatAdminMinorUnits(row.total_bet_minor, defaultCurrency) - : "—"} + + {row.total_bet_minor != null ? ( + + {formatAdminMinorUnits(row.total_bet_minor, defaultCurrency)} + + ) : "—"} - - {row.total_payout_minor != null - ? formatAdminMinorUnits(row.total_payout_minor, defaultCurrency) - : "—"} + + {row.total_payout_minor != null ? ( + + {formatAdminMinorUnits(row.total_payout_minor, defaultCurrency)} + + ) : "—"} - {row.profit_loss_minor != null - ? formatAdminMinorUnits(row.profit_loss_minor, defaultCurrency) - : "—"} + {row.profit_loss_minor != null ? ( + + {formatAdminMinorUnits(row.profit_loss_minor, defaultCurrency)} + + ) : "—"} ) : null} diff --git a/src/modules/reconcile/reconcile-console.tsx b/src/modules/reconcile/reconcile-console.tsx index fa50e61..c9b35fd 100644 --- a/src/modules/reconcile/reconcile-console.tsx +++ b/src/modules/reconcile/reconcile-console.tsx @@ -343,17 +343,21 @@ export function ReconcileConsole(): React.ReactElement { {t("createTitle")}

{t("createHint")}

- -
-
-
- - -
-
+ +
+
+ {t("reconcileType")} + + {t("reconcileTypeFixed")} + +
+
+ +
{ @@ -363,102 +367,101 @@ export function ReconcileConsole(): React.ReactElement { />
- -
-
- - setPlayerSearch(e.target.value)} - placeholder={t("playerSearchPlaceholder")} - /> -
- - {selectedPlayer ? ( -
-
- {selectedPlayer.site_player_id} - {selectedPlayer.nickname ? ` · ${selectedPlayer.nickname}` : ""} - {selectedPlayer.username ? ` · ${selectedPlayer.username}` : ""} - {` · ${selectedPlayer.site_code}`} -
- -
- ) : null} - - {playerSearch.trim() !== "" || playerResults.length > 0 || playerLoading ? ( -
-
- {playerLoading ? ( - - ) : playerResults.length === 0 ? ( - - ) : ( -
- {playerResults.map((player) => { - const active = selectedPlayer?.id === player.id; - return ( - - ); - })} -
- )} -
-
- ) : null} +
+ + setPlayerSearch(e.target.value)} + placeholder={t("playerSearchPlaceholder")} + /> +
+
+
-
- -
+ {selectedPlayer ? ( +
+
+ {selectedPlayer.site_player_id} + {selectedPlayer.nickname ? ` · ${selectedPlayer.nickname}` : ""} + {selectedPlayer.username ? ` · ${selectedPlayer.username}` : ""} + {` · ${selectedPlayer.site_code}`} +
+ +
+ ) : null} + + {playerSearch.trim() !== "" || playerResults.length > 0 || playerLoading ? ( +
+
+ {playerLoading ? ( + + ) : playerResults.length === 0 ? ( + + ) : ( +
+ {playerResults.map((player) => { + const active = selectedPlayer?.id === player.id; + return ( + + ); + })} +
+ )} +
+
+ ) : null} ) : ( diff --git a/src/modules/reports/reports-console.tsx b/src/modules/reports/reports-console.tsx index a4c2831..a626dc9 100644 --- a/src/modules/reports/reports-console.tsx +++ b/src/modules/reports/reports-console.tsx @@ -6,7 +6,6 @@ import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { CalendarDays, - CircleDollarSign, Database, FileDown, FileSpreadsheet, @@ -31,7 +30,6 @@ import { getAdminReportDailyProfit, getAdminReportPlayDimension, getAdminReportPlayerWinLoss, - getAdminReportRebateCommission, } from "@/api/admin-reports"; import { buildReportJobParameters, @@ -42,7 +40,13 @@ import { getAdminRiskPoolDetail, getAdminRiskPools } from "@/api/admin-risk"; import { getAdminUsers } from "@/api/admin-users"; import { getAdminTransferOrders } from "@/api/admin-wallet"; import { adminHasAnyPermission } from "@/lib/admin-permissions"; -import { PRD_REPORT_EXPORT, PRD_REPORT_VIEW } from "@/lib/admin-prd"; +import { + PRD_AUDIT_VIEW, + PRD_REPORT_EXPORT, + PRD_REPORT_VIEW, + PRD_RISK_ACCESS_ANY, + PRD_WALLET_TRANSFER_ACCESS_ANY, +} from "@/lib/admin-prd"; import { useAdminProfile } from "@/stores/admin-session"; import { adminAgentDisplayLabel } from "@/components/admin/admin-agent-columns"; import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state"; @@ -73,6 +77,7 @@ import { useAdminCurrencyCatalog, getCachedAdminCurrencies } from "@/hooks/use-a import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"; import { formatAdminInstant } from "@/lib/admin-datetime"; import { getAdminRequestLocale } from "@/lib/admin-locale"; +import { AdminMoneyDisplay } from "@/components/admin/admin-money-display"; import { signedMoneyClass } from "@/lib/admin-signed-money"; import { cn } from "@/lib/utils"; import { formatAdminMinorUnits } from "@/lib/money"; @@ -88,7 +93,6 @@ import type { AdminReportDailyProfitRow, AdminReportPlayDimensionRow, AdminReportPlayerWinLossRow, - AdminReportRebateCommissionRow, } from "@/types/api/admin-reports"; export type ReportCategory = "profit" | "wallet" | "risk" | "audit"; @@ -107,7 +111,6 @@ type ReportKey = | "hot_number_risk" | "play_dimension" | "sold_out_number" - | "rebate_commission" | "admin_audit"; type ReportDefinition = { @@ -118,8 +121,22 @@ type ReportDefinition = { scope: string; fields: FieldKey[]; connected: boolean; + requiredAny: readonly string[]; }; +const PRD_REPORTS_VIEW_ACCESS_ANY = [PRD_REPORT_VIEW] as const; + +const REPORTS: ReportDefinition[] = [ + { key: "draw_profit", category: "profit", icon: Ticket, filterKind: "draw", scope: "drawNo", fields: ["drawNo"], connected: true, requiredAny: PRD_REPORTS_VIEW_ACCESS_ANY }, + { key: "daily_profit", category: "profit", icon: CalendarDays, filterKind: "date", scope: "date", fields: ["period"], connected: true, requiredAny: PRD_REPORTS_VIEW_ACCESS_ANY }, + { key: "player_win_loss", category: "profit", icon: Users, filterKind: "player_period", scope: "playerPeriod", fields: ["player", "period"], connected: true, requiredAny: PRD_REPORTS_VIEW_ACCESS_ANY }, + { key: "player_transfer", category: "wallet", icon: WalletCards, filterKind: "player_period", scope: "playerPeriod", fields: ["player", "period"], connected: true, requiredAny: PRD_WALLET_TRANSFER_ACCESS_ANY }, + { key: "hot_number_risk", category: "risk", icon: ShieldAlert, filterKind: "draw_number", scope: "drawNumber", fields: ["drawNo", "number"], connected: true, requiredAny: PRD_RISK_ACCESS_ANY }, + { key: "play_dimension", category: "profit", icon: ListFilter, filterKind: "play_period", scope: "playPeriod", fields: ["play", "period"], connected: true, requiredAny: PRD_REPORTS_VIEW_ACCESS_ANY }, + { key: "sold_out_number", category: "risk", icon: ShieldCheck, filterKind: "draw", scope: "drawNo", fields: ["drawNo"], connected: true, requiredAny: PRD_RISK_ACCESS_ANY }, + { key: "admin_audit", category: "audit", icon: FileSpreadsheet, filterKind: "operator_period", scope: "operatorPeriod", fields: ["operator", "period"], connected: true, requiredAny: [PRD_AUDIT_VIEW] }, +]; + type PreviewColumns = { primary: string; secondary: string; @@ -159,7 +176,6 @@ type ReportResult = | { key: "hot_number_risk"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta | null; raw: AdminRiskPoolShowData } | { key: "play_dimension"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta; raw: AdminReportPlayDimensionRow[] } | { key: "sold_out_number"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta; raw: AdminRiskPoolRow[] } - | { key: "rebate_commission"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta; raw: AdminReportRebateCommissionRow[] } | { key: "admin_audit"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta; raw: AdminAuditLogRow[] }; type StatCard = { @@ -182,18 +198,6 @@ type PlayOption = { label: string; }; -const REPORTS: ReportDefinition[] = [ - { key: "draw_profit", category: "profit", icon: Ticket, filterKind: "draw", scope: "drawNo", fields: ["drawNo"], connected: true }, - { key: "daily_profit", category: "profit", icon: CalendarDays, filterKind: "date", scope: "date", fields: ["period"], connected: true }, - { key: "player_win_loss", category: "profit", icon: Users, filterKind: "player_period", scope: "playerPeriod", fields: ["player", "period"], connected: true }, - { key: "player_transfer", category: "wallet", icon: WalletCards, filterKind: "player_period", scope: "playerPeriod", fields: ["player", "period"], connected: true }, - { key: "hot_number_risk", category: "risk", icon: ShieldAlert, filterKind: "draw_number", scope: "drawNumber", fields: ["drawNo", "number"], connected: true }, - { key: "play_dimension", category: "profit", icon: ListFilter, filterKind: "play_period", scope: "playPeriod", fields: ["play", "period"], connected: true }, - { key: "sold_out_number", category: "risk", icon: ShieldCheck, filterKind: "draw", scope: "drawNo", fields: ["drawNo"], connected: true }, - { key: "rebate_commission", category: "profit", icon: CircleDollarSign, filterKind: "play_period", scope: "playPeriod", fields: ["play", "period"], connected: true }, - { key: "admin_audit", category: "audit", icon: FileSpreadsheet, filterKind: "operator_period", scope: "operatorPeriod", fields: ["operator", "period"], connected: true }, -]; - const emptyFilters: ReportFilters = { drawNo: "", drawId: null, @@ -414,38 +418,6 @@ function buildPlayDimensionRowsAndSummary( }; } -function buildRebateCommissionRowsAndSummary( - items: AdminReportRebateCommissionRow[], - total: number, - t: (key: string) => string, - pageScopedLabel: (statKey: string) => string, - currencyCode: string, -): Pick, "rows" | "summary"> { - let totalRebate = 0; - let totalOrders = 0; - - const rows = items.map((item) => { - totalRebate += item.total_rebate_minor; - totalOrders += item.order_count; - return { - play_code: item.play_code, - total_rebate_minor: item.total_rebate_minor, - order_count: item.order_count, - ticket_item_count: item.ticket_item_count, - }; - }); - - return { - rows, - summary: [ - { label: t("preview.stats.records"), value: String(total) }, - { label: t("preview.stats.currentPage"), value: String(items.length) }, - { label: pageScopedLabel("rebate"), value: formatPlainMoney(totalRebate, currencyCode) }, - { label: pageScopedLabel("orders"), value: String(totalOrders) }, - ], - }; -} - function metaFromList(meta: { current_page: number; per_page: number; total: number; last_page: number }): ReportMeta { return { total: meta.total, @@ -612,13 +584,6 @@ function defaultSummaryCards( { label: t("preview.stats.currency"), value: t("preview.stats.notQueried") }, { label: t("preview.stats.usage"), value: t("preview.stats.notQueried") }, ]; - case "rebate_commission": - return [ - { label: t("preview.stats.records"), value: t("preview.stats.notQueried") }, - { label: t("fields.play"), value: filters.play || t("filterAll") }, - { label: t("preview.stats.rebate"), value: t("preview.stats.notQueried") }, - { label: t("preview.stats.orders"), value: t("preview.stats.notQueried") }, - ]; case "admin_audit": return [ { label: t("preview.stats.records"), value: t("preview.stats.notQueried") }, @@ -641,14 +606,15 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa const profile = useAdminProfile(); const canViewReports = adminHasAnyPermission(profile?.permissions, [PRD_REPORT_VIEW]); const canExportReports = adminHasAnyPermission(profile?.permissions, [PRD_REPORT_EXPORT]); + const permissionSlugs = useMemo(() => profile?.permissions ?? [], [profile?.permissions]); useAdminCurrencyCatalog(); useAdminPlayTypeCatalog(); const playCodeLabel = useAdminPlayCodeLabel(); const formatTs = useAdminDateTimeFormatter(); - const filteredReports = useMemo( - () => (initialCategory ? REPORTS.filter((report) => report.category === initialCategory) : REPORTS), - [initialCategory], - ); + const filteredReports = useMemo(() => { + const visible = REPORTS.filter((report) => adminHasAnyPermission(permissionSlugs, report.requiredAny)); + return initialCategory ? visible.filter((report) => report.category === initialCategory) : visible; + }, [initialCategory, permissionSlugs]); const [selectedKey, setSelectedKey] = useState( filteredReports[0]?.key ?? REPORTS[0].key, ); @@ -758,17 +724,6 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa extra: t("preview.columns.soldOut.extra"), time: t("preview.columns.soldOut.time"), }; - case "rebate_commission": - return { - primary: t("preview.columns.rebateCommission.primary"), - secondary: t("preview.columns.rebateCommission.secondary"), - metricA: t("preview.columns.rebateCommission.metricA"), - metricB: t("preview.columns.rebateCommission.metricB"), - metricC: t("preview.columns.rebateCommission.metricC"), - status: t("preview.columns.rebateCommission.status"), - extra: t("preview.columns.rebateCommission.extra"), - time: t("preview.columns.rebateCommission.time"), - }; case "admin_audit": return { primary: t("preview.columns.adminAudit.primary"), @@ -1057,22 +1012,6 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa }); break; } - case "rebate_commission": { - const payload = await getAdminReportRebateCommission( - reportListParams(filters, page, perPage), - ); - const currencyCode = resolveDisplayCurrency(payload.currency_code); - setDisplayCurrency(currencyCode); - const next = buildRebateCommissionRowsAndSummary(payload.items, payload.meta.total, t, pageScopedLabel, currencyCode); - setResult({ - key: "rebate_commission", - raw: payload.items, - rows: next.rows, - meta: metaFromList(payload.meta), - summary: next.summary, - }); - break; - } case "admin_audit": { const operatorId = filters.operatorId ?? parsePositiveInteger(filters.operator); const payload = await getAdminAuditLogs({ @@ -1134,10 +1073,10 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa ...prev, drawNo: drawNoFromUrl || prev.drawNo, })); - if (drawNoFromUrl) { + if (drawNoFromUrl && filteredReports.some((report) => report.key === "draw_profit")) { setSelectedKey("draw_profit"); } - }, [drawNoFromUrl]); + }, [drawNoFromUrl, filteredReports]); useEffect(() => { queueMicrotask(() => { @@ -1563,21 +1502,6 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa )); } - if (result.key === "rebate_commission") { - return result.raw.map((item) => ( - - {playCodeLabel(item.play_code)} - {item.order_count} - {formatPlainMoney(item.total_rebate_minor, displayCurrency)} - {item.ticket_item_count} - - - - - - - - - - )); - } - if (result.key === "admin_audit") { return result.raw.map((item) => ( @@ -1598,6 +1522,10 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa return (
+ {filteredReports.length === 0 ? ( + + ) : ( + <>
@@ -1652,11 +1580,13 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa -
+
{(result?.summary ?? defaultSummaryCards(selectedReport.key, filters, t)).map((item) => ( -
+
{item.label}
-
{item.value}
+ + {item.value} +
))}
@@ -1722,6 +1652,8 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa ) : null} + + )}
); } diff --git a/src/modules/settlement/settlement-bill-breakdown.tsx b/src/modules/settlement/settlement-bill-breakdown.tsx index d4764f0..25ff949 100644 --- a/src/modules/settlement/settlement-bill-breakdown.tsx +++ b/src/modules/settlement/settlement-bill-breakdown.tsx @@ -4,6 +4,7 @@ import { ArrowRight } from "lucide-react"; import { useTranslation } from "react-i18next"; import type { SettlementBillRow } from "@/api/admin-agent-settlement"; +import { AdminMoneyDisplay } from "@/components/admin/admin-money-display"; import { AdminStatusBadge } from "@/components/admin/admin-status-badge"; import { cn } from "@/lib/utils"; import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics"; @@ -64,9 +65,15 @@ export function SettlementBillSummaryHeader({

{t("settlementCenter:billDisplay.settlementAmount", { defaultValue: "结算金额" })}

-

+ {formatDashboardMoneyMinor(direction.amount, currencyCode)} -

+
@@ -75,9 +82,15 @@ export function SettlementBillSummaryHeader({

{t("settlementCenter:columns.paid", { defaultValue: "已收付" })}

-

+ {formatDashboardMoneyMinor(bill.paid_amount ?? 0, currencyCode)} -

+
{t("settlementCenter:columns.unpaid", { defaultValue: "未结" })}

-

{formatDashboardMoneyMinor(bill.unpaid_amount, currencyCode)} -

+ {unpaid ? (

{bill.status === "pending_confirm" diff --git a/src/modules/settlement/settlement-bills-table.tsx b/src/modules/settlement/settlement-bills-table.tsx index 1a48403..526861e 100644 --- a/src/modules/settlement/settlement-bills-table.tsx +++ b/src/modules/settlement/settlement-bills-table.tsx @@ -8,6 +8,7 @@ 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 { AdminTableMoney, adminMoneyCellClassName } from "@/components/admin/admin-table-money"; import { signedMoneyClass } from "@/lib/admin-signed-money"; import { cn } from "@/lib/utils"; import { formatSettlementPeriodSpan } from "@/lib/agent-settlement-period-range"; @@ -243,16 +244,20 @@ export function SettlementBillsTable({ )} ) : null} - -

+ + {formatDashboardMoneyMinor(direction.amount, currencyCode)} -
+ - - {formatDashboardMoneyMinor(row.paid_amount ?? 0, currencyCode)} + + + {formatDashboardMoneyMinor(row.paid_amount ?? 0, currencyCode)} + - - {formatDashboardMoneyMinor(row.unpaid_amount, currencyCode)} + + + {formatDashboardMoneyMinor(row.unpaid_amount, currencyCode)} + diff --git a/src/modules/settlement/settlement-operations-panel.tsx b/src/modules/settlement/settlement-operations-panel.tsx index caf06d2..a2b09bc 100644 --- a/src/modules/settlement/settlement-operations-panel.tsx +++ b/src/modules/settlement/settlement-operations-panel.tsx @@ -34,6 +34,7 @@ import { TableRow, } from "@/components/ui/table"; import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"; +import { AdminTableMoney, adminMoneyCellClassName } from "@/components/admin/admin-table-money"; import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics"; import { LotteryApiBizError } from "@/types/api/errors"; import { settlementAdjustmentTypeLabel } from "@/modules/settlement/settlement-status-label"; @@ -368,8 +369,10 @@ export function SettlementOperationsPanel({ {row.billId > 0 ? `#${row.billId}` : "—"} - - {formatDashboardMoneyMinor(row.amount, currencyCode)} + + + {formatDashboardMoneyMinor(row.amount, currencyCode)} + {row.summary} diff --git a/src/modules/wallet/wallet-console.tsx b/src/modules/wallet/wallet-console.tsx index 9939cb3..e49b112 100644 --- a/src/modules/wallet/wallet-console.tsx +++ b/src/modules/wallet/wallet-console.tsx @@ -59,6 +59,7 @@ import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter" import { useConfirmAction } from "@/hooks/use-confirm-action"; import { useExportLabels } from "@/hooks/use-export-labels"; import { PlayerLedgerSourceBadge } from "@/components/admin/player-funding-badges"; +import { AdminTableMoney, adminMoneyCellClassName } from "@/components/admin/admin-table-money"; import { formatAdminMinorUnits } from "@/lib/money"; import { creditLedgerReasonLabel } from "@/modules/settlement/settlement-status-label"; import { LotteryApiBizError } from "@/types/api/errors"; @@ -578,8 +579,10 @@ export function TransferOrdersPanel(): React.ReactElement { {row.direction} - - {formatAdminMinorUnits(row.amount, row.currency_code)} + + + {formatAdminMinorUnits(row.amount, row.currency_code)} + {statusLabelT(row.status, t)} @@ -907,8 +910,10 @@ export function WalletTxnsPanel(): React.ReactElement { {walletTxnBizTypeLabel(row.biz_type, row.ledger_source, t, tSettlement)} - - {row.amount_formatted ?? formatAdminMinorUnits(row.amount)} + + + {row.amount_formatted ?? formatAdminMinorUnits(row.amount)} + ({row.direction === 1 ? t("in") : t("out")}) @@ -1031,9 +1036,13 @@ export function PlayerWalletPanel(): React.ReactElement { {w.wallet_type} {w.currency_code} - {w.balance} - - {formatAdminMinorUnits(w.available_balance, w.currency_code)} + + {w.balance} + + + + {formatAdminMinorUnits(w.available_balance, w.currency_code)} + )) diff --git a/src/stores/admin-profile.ts b/src/stores/admin-profile.ts index 1a70759..f07d781 100644 --- a/src/stores/admin-profile.ts +++ b/src/stores/admin-profile.ts @@ -21,6 +21,9 @@ export function readProfile(): AdminProfile | null { const permissions = Array.isArray(v.permissions) ? v.permissions.filter((s): s is string => typeof s === "string") : []; + const operationalPermissions = Array.isArray(v.operational_permissions) + ? v.operational_permissions.filter((s): s is string => typeof s === "string") + : []; const navigation = Array.isArray(v.navigation) ? v.navigation.filter((item): item is AdminNavItem => { return ( @@ -32,13 +35,26 @@ export function readProfile(): AdminProfile | null { ); }) : []; + const accountKind = + v.account_kind === "super_admin" + || v.account_kind === "site_admin" + || v.account_kind === "site_finance" + || v.account_kind === "site_cs" + || v.account_kind === "site_operator" + || v.account_kind === "agent_operator" + || v.account_kind === "platform_account" + ? (v.account_kind === "site_operator" ? "site_admin" : v.account_kind) + : undefined; return { id: v.id, username: v.username, nickname: v.nickname, email: typeof v.email === "string" || v.email === null ? v.email : null, permissions, + operational_permissions: operationalPermissions, navigation, + is_super_admin: v.is_super_admin === true ? true : undefined, + account_kind: accountKind, }; } } catch { diff --git a/src/types/api/admin-auth.ts b/src/types/api/admin-auth.ts index b52727a..e634603 100644 --- a/src/types/api/admin-auth.ts +++ b/src/types/api/admin-auth.ts @@ -16,20 +16,31 @@ export type AdminAuthLoginRequest = { import type { AdminAgentContext } from "@/types/api/admin-agent"; +/** 登录态账号形态(与 Laravel `auth/me.account_kind` 对齐) */ +export type AdminAccountKind = + | "super_admin" + | "site_admin" + | "site_finance" + | "site_cs" + | "agent_operator" + | "platform_account"; + /** 登录成功后缓存于会话(localStorage)的管理员摘要 */ export type AdminProfile = { id: number; username: string; nickname: string; email: string | null; - /** 与 Laravel `admin_permissions.slug` 一致(如 `prd.*`);超管为全量列表 */ + /** Legacy 产品权限 slug(如 `prd.*`);侧栏与旧页面门控 */ permissions?: string[]; /** 当前管理员可见的后台菜单,由 Laravel 注册表统一下发。 */ navigation?: AdminNavItem[]; /** 代理账号绑定节点;超管为 null */ agent?: AdminAgentContext | null; is_super_admin?: boolean; - /** 与 permissions 同值,语义上强调“可操作权限” */ + /** 账号形态:超管 / 站点运营 / 代理经营 / 其他平台账号 */ + account_kind?: AdminAccountKind; + /** API 鉴权 action code(如 `service.report.view`);与后端 `effectiveMenuActionPermissionCodes` 一致 */ operational_permissions?: string[]; /** 当前代理可下放给下级的 prd.* 上限(未配置 grants 时与操作权限一致) */ delegation_ceiling?: string[]; diff --git a/src/types/api/admin-dashboard.ts b/src/types/api/admin-dashboard.ts index 438a5e4..742816e 100644 --- a/src/types/api/admin-dashboard.ts +++ b/src/types/api/admin-dashboard.ts @@ -49,6 +49,31 @@ export type AdminDashboardCapabilities = { wallet_transfer_view: boolean; }; +export type AdminDashboardSiteFinanceOverview = { + admin_site_id: number; + site_code: string; + site_name: string; + wallet_player_count: number; + credit_player_count: number; + pending_confirm_bill_count: number; + payable_bill_count: number; + payable_unpaid_minor: number; + pending_bill_count: number; + pending_unpaid_minor: number; + abnormal_transfer_count: number; + currency_code: string | null; +}; + +export type AdminDashboardSiteCsOverview = { + admin_site_id: number; + site_code: string; + site_name: string; + player_count: number; + ticket_order_count_today: number; + active_player_count_today: number; + latest_ticket_at: string | null; +}; + /** 站点管理员首页摘要(`GET /api/v1/admin/dashboard` → `site_overview`) */ export type AdminDashboardSiteOverview = { admin_site_id: number; @@ -178,4 +203,6 @@ export type AdminDashboardData = { capabilities: AdminDashboardCapabilities; agent_overview: AdminDashboardAgentOverview | null; site_overview: AdminDashboardSiteOverview | null; + site_finance_overview: AdminDashboardSiteFinanceOverview | null; + site_cs_overview: AdminDashboardSiteCsOverview | null; }; diff --git a/src/types/api/admin-reports.ts b/src/types/api/admin-reports.ts index ba81c2f..1789d11 100644 --- a/src/types/api/admin-reports.ts +++ b/src/types/api/admin-reports.ts @@ -24,13 +24,6 @@ export type AdminReportPlayDimensionRow = { approx_house_gross_minor: number; }; -export type AdminReportRebateCommissionRow = { - play_code: string; - total_rebate_minor: number; - order_count: number; - ticket_item_count: number; -}; - export type AdminReportListData = { items: T[]; meta: {