diff --git a/src/api/admin-agent-settlement.ts b/src/api/admin-agent-settlement.ts index 150c30a..3865d5d 100644 --- a/src/api/admin-agent-settlement.ts +++ b/src/api/admin-agent-settlement.ts @@ -10,11 +10,16 @@ export type SettlementPeriodSummary = { awaiting_payment: number; settled: number; total_unpaid: number; + total_net?: number; }; export type SettlementPeriodPipeline = { credit_ledger_count: number; share_ledger_count: number; + game_win_loss_total?: number; + win_loss_scope?: "platform" | "agent"; + basic_rebate_total?: number; + unsettled_ticket_count?: number; }; export type SettlementPeriodRow = { @@ -55,6 +60,12 @@ export type SettlementBillRow = { status: string; owner_label?: string; counterparty_label?: string; + player_username?: string | null; + player_site_player_id?: string | null; + player_id_display?: number | null; + direct_agent_label?: string | null; + superior_agent_label?: string | null; + owner_party_label?: string | null; owner_funding_mode?: string | null; owner_auth_source?: string | null; period_start?: string; @@ -102,12 +113,71 @@ export type SettlementBillListScope = | "settled" | "adjustment"; +export type SettlementCreditLedgerRow = { + entry_kind: string; + id: number; + row_key: string; + txn_no: string; + player_id: number; + site_code?: string | null; + username?: string | null; + nickname?: string | null; + site_player_id?: string | null; + biz_type: string; + biz_no?: string | null; + ref_type?: string | null; + ref_id?: number | null; + direction: 1 | 2; + amount: number; + amount_formatted?: string; + signed_amount?: number; + currency_code?: string; + status: string; + created_at?: string | null; + ledger_source: string; + funding_mode?: string | null; + direct_agent_label?: string | null; + parent_agent_label?: string | null; + play_code?: string | null; + draw_no?: string | null; + ticket_item_id?: number | null; +}; + +export async function getCreditLedger(params: { + admin_site_id: number; + settlement_period_id: number; + entry_kind?: "credit"; + bet_flow_only?: boolean; + bet_flow_display?: "simple"; + player_account?: string; + reason?: string; + page?: number; + per_page?: number; +}): Promise<{ + items: SettlementCreditLedgerRow[]; + total: number; + page: number; + per_page: number; + ledger_source: string; +}> { + return adminRequest.get(`${A}/credit-ledger`, { params }); +} + export async function getSettlementBills(params?: { settlement_period_id?: number; admin_site_id?: number; bill_type?: string; scope?: SettlementBillListScope; -}): Promise<{ items: SettlementBillRow[] }> { + bill_id?: number; + keyword?: string; + page?: number; + per_page?: number; +}): Promise<{ + items: SettlementBillRow[]; + total?: number; + page?: number; + per_page?: number; +}> { return adminRequest.get(`${A}/settlement-bills`, { params }); } @@ -163,70 +233,13 @@ export async function getSettlementAdjustments(params?: { return adminRequest.get(`${A}/settlement-adjustments`, { params }); } -export type SettlementLedgerRow = { - entry_kind: "credit" | "payment" | "adjustment"; - id: number; - row_key?: string; - txn_no: string; - player_id: number; - site_code?: string; - site_player_id?: string | null; - username?: string | null; - nickname?: string | null; - biz_type: string; - type?: string; - biz_no?: string | null; - direction: number; - amount: number; - amount_formatted?: string; - signed_amount?: number; - currency_code?: string; - status: string; - created_at?: string | null; - ledger_source: string; - funding_mode?: string; - auth_source?: string | null; - settlement_bill_id?: number | null; - bill_status?: string | null; - bill_type?: string | null; - bill_unpaid_amount?: number | null; - available_actions?: string[]; -}; - -/** @deprecated Use {@link SettlementLedgerRow} */ -export type CreditLedgerRow = SettlementLedgerRow; - -export async function getCreditLedger(params?: { - admin_site_id?: number; - settlement_period_id?: number; - player_id?: number; - player_account?: string; - txn_no?: string; - reason?: string; - biz_type?: string; - entry_kind?: string; - bill_status?: string; - actionable_only?: boolean; - bad_debt_only?: boolean; - created_from?: string; - created_to?: string; - page?: number; - per_page?: number; -}): Promise<{ - items: SettlementLedgerRow[]; - total: number; - page: number; - per_page: number; - ledger_source: string; -}> { - return adminRequest.get(`${A}/credit-ledger`, { params }); -} - export type RebateAllocationRow = { id: number; rebate_record_id: number; + settlement_bill_id?: number; participant_type: string; participant_id: number; + participant_label?: string; actual_share_rate: number; allocated_amount: number; allocation_rule: string; diff --git a/src/app/admin/(shell)/config/rebate/page.tsx b/src/app/admin/(shell)/config/rebate/page.tsx index 6df3bca..a4e7c33 100644 --- a/src/app/admin/(shell)/config/rebate/page.tsx +++ b/src/app/admin/(shell)/config/rebate/page.tsx @@ -1,5 +1,5 @@ import { redirect } from "next/navigation"; export default function AdminConfigRebateRedirectPage() { - redirect("/admin/rules/odds#rebate"); + redirect("/admin/rules/odds"); } diff --git a/src/components/admin/admin-list-pagination-footer.tsx b/src/components/admin/admin-list-pagination-footer.tsx index 45c6eba..afa54d9 100644 --- a/src/components/admin/admin-list-pagination-footer.tsx +++ b/src/components/admin/admin-list-pagination-footer.tsx @@ -81,7 +81,9 @@ export function AdminPerPagePicker({ }} > - + + {(v) => (v == null || v === "" ? String(perPage) : String(v))} + {ADMIN_LIST_PER_PAGE_OPTIONS.map((n) => ( diff --git a/src/components/admin/admin-no-resource-state.tsx b/src/components/admin/admin-no-resource-state.tsx index 38fdf23..a6fed41 100644 --- a/src/components/admin/admin-no-resource-state.tsx +++ b/src/components/admin/admin-no-resource-state.tsx @@ -30,7 +30,7 @@ export function AdminNoResourceState({

diff --git a/src/components/admin/admin-sidebar-nav.tsx b/src/components/admin/admin-sidebar-nav.tsx index d0bb3b6..9ef0ceb 100644 --- a/src/components/admin/admin-sidebar-nav.tsx +++ b/src/components/admin/admin-sidebar-nav.tsx @@ -14,6 +14,7 @@ import { SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem, + useSidebar, } from "@/components/ui/sidebar"; import { ADMIN_NAV_GROUP_ICON, @@ -29,7 +30,7 @@ const NAV_BTN = "h-8 gap-2 px-2.5 py-0 text-[13px] leading-snug font-normal text-sidebar-foreground/90 hover:text-sidebar-accent-foreground [&_svg]:size-4"; const NAV_ACTIVE = "data-active:bg-red-600 data-active:text-white data-active:font-medium data-active:shadow-sm"; const SUB_NAV = - "h-8 min-h-8 rounded-sm px-2.5 py-0 text-sm leading-snug font-normal text-sidebar-foreground/90 hover:text-sidebar-accent-foreground data-[size=md]:text-sm data-[size=sm]:text-sm [&>span]:text-sm"; + "h-8 min-h-8 gap-2 rounded-sm px-2.5 py-0 text-sm leading-snug font-normal text-sidebar-foreground/90 hover:text-sidebar-accent-foreground data-[size=md]:text-sm data-[size=sm]:text-sm [&>span]:text-sm [&_svg]:size-4"; function isActive( pathname: string, @@ -101,6 +102,7 @@ function NavSubLeaf({ pathname: string; t: TFunction; }): ReactElement { + const Icon = resolveAdminNavIcon(item.segment); const active = isActive(pathname, item); const label = adminNavLabel(item.segment, t, item.label); @@ -112,6 +114,7 @@ function NavSubLeaf({ render={} className={cn(SUB_NAV, NAV_ACTIVE)} > + {label} @@ -175,7 +178,9 @@ export function AdminSidebarNav({ }): ReactElement { const { t } = useTranslation("common"); const pathname = usePathname(); + const { state } = useSidebar(); const navGroups = useMemo(() => groupAdminNavItems(items), [items]); + const flatItems = useMemo(() => navGroups.flatMap((g) => g.items), [navGroups]); const [openGroups, setOpenGroups] = useState>(() => defaultOpenGroups(navGroups, pathname), @@ -195,6 +200,16 @@ export function AdminSidebarNav({ }); }, [pathname, navGroups]); + if (state === "collapsed") { + return ( + + {flatItems.map((item) => ( + + ))} + + ); + } + const overview = navGroups.find((g) => g.group === "overview"); const collapsible = navGroups.filter((g) => g.group !== "overview"); diff --git a/src/components/admin/admin-subnav.tsx b/src/components/admin/admin-subnav.tsx new file mode 100644 index 0000000..bdf12ec --- /dev/null +++ b/src/components/admin/admin-subnav.tsx @@ -0,0 +1,128 @@ +"use client"; + +import Link from "next/link"; +import type { ReactNode } from "react"; + +import { cn } from "@/lib/utils"; + +export function adminSubnavItemClassName(active: boolean, disabled = false): string { + if (disabled) { + return "border-b-2 border-transparent px-4 py-3 text-sm font-medium text-muted-foreground/45 cursor-not-allowed"; + } + + return cn( + "relative -mb-px shrink-0 border-b-2 px-4 py-3 text-sm font-medium transition-colors", + active + ? "border-primary text-primary" + : "border-transparent text-muted-foreground hover:border-border/80 hover:text-foreground", + ); +} + +type AdminSubnavProps = { + children: ReactNode; + className?: string; + /** Accessible name for the tab list */ + "aria-label": string; +}; + +/** Underline-style horizontal tab bar used across admin page headers. */ +export function AdminSubnav({ children, className, "aria-label": ariaLabel }: AdminSubnavProps) { + return ( +

+ ); +} + +type AdminSubnavLinkProps = { + href: string; + active: boolean; + children: ReactNode; + className?: string; + disabled?: boolean; + disabledTitle?: string; +}; + +export function AdminSubnavLink({ + href, + active, + children, + className, + disabled = false, + disabledTitle, +}: AdminSubnavLinkProps) { + if (disabled) { + return ( + + {children} + + ); + } + + return ( + + {children} + + ); +} + +type AdminSubnavButtonProps = { + active: boolean; + onClick: () => void; + children: ReactNode; + className?: string; + count?: number; + disabled?: boolean; +}; + +export function AdminSubnavButton({ + active, + onClick, + children, + className, + count, + disabled = false, +}: AdminSubnavButtonProps) { + return ( + + ); +} + +type AdminSubnavBarProps = { + children: ReactNode; + trailing?: ReactNode; + className?: string; +}; + +/** Wrapper for subnav plus optional trailing controls (site selector, back link, etc.). */ +export function AdminSubnavBar({ children, trailing, className }: AdminSubnavBarProps) { + return ( +
+ {children} + {trailing ?
{trailing}
: null} +
+ ); +} diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx index 8ee8054..993cb33 100644 --- a/src/components/ui/tabs.tsx +++ b/src/components/ui/tabs.tsx @@ -24,7 +24,7 @@ function Tabs({ } const tabsListVariants = cva( - "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none", + "group/tabs-list inline-flex w-fit items-end justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none", { variants: { variant: { @@ -59,9 +59,10 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) { data-slot="tabs-trigger" className={cn( "relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "group-data-[variant=line]/tabs-list:-mb-px group-data-[variant=line]/tabs-list:h-auto group-data-[variant=line]/tabs-list:shrink-0 group-data-[variant=line]/tabs-list:flex-none group-data-[variant=line]/tabs-list:rounded-none group-data-[variant=line]/tabs-list:border-b-2 group-data-[variant=line]/tabs-list:border-transparent group-data-[variant=line]/tabs-list:px-4 group-data-[variant=line]/tabs-list:py-3 group-data-[variant=line]/tabs-list:text-muted-foreground group-data-[variant=line]/tabs-list:hover:border-border/80 group-data-[variant=line]/tabs-list:hover:text-foreground group-data-[variant=line]/tabs-list:data-active:border-primary group-data-[variant=line]/tabs-list:data-active:bg-transparent group-data-[variant=line]/tabs-list:data-active:text-primary dark:group-data-[variant=line]/tabs-list:data-active:border-primary dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent", "group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent", "data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground", - "after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100", + "after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:after:hidden group-data-[variant=line]/tabs-list:data-active:after:opacity-0", className )} {...props} diff --git a/src/i18n/locales/en/config.json b/src/i18n/locales/en/config.json index 6331abd..ef1f187 100644 --- a/src/i18n/locales/en/config.json +++ b/src/i18n/locales/en/config.json @@ -17,6 +17,7 @@ "rulesPlaysTitle": "Play rules", "rulesOddsTitle": "Odds & rebate", "rulesOddsDescription": "Odds matrix and rebate rates on one page, sharing the same odds version line.", + "rulesOddsDescriptionShort": "Pick a play on the left, edit odds and rebate on the right, then save and publish.", "riskCapTitle": "Risk cap rules" }, "hub": { @@ -411,6 +412,16 @@ "oddsConfig": "Odds" }, "currentSelection": "Selection: {{category}} / {{play}}", + "playSelectPlaceholder": "Select play type", + "readOnlyBanner": "This version is read-only. Create a draft to edit odds and rebate.", + "table": { + "prizeScope": "Prize scope", + "multiplier": "Odds multiplier" + }, + "draftBar": { + "unsaved": "Unsaved changes", + "saved": "Changes kept in local draft" + }, "playGroups": { "bigSmall": "Big / small", "combo4": "4D position", @@ -420,10 +431,13 @@ }, "summary": { "title": "Summary", - "version": "Version", + "contextTitle": "Version & tips", + "version": "Editing version", + "activeVersion": "Active version", "statusLabel": "Status", "readOnlyTag": "Read-only", "readOnlyHint": "This version is read-only. Create a draft to make changes.", + "draftHint": "Save draft changes before publishing; publish affects new tickets only.", "activeHint": "This version is active; new tickets use these settings." }, "tabs": { diff --git a/src/i18n/locales/en/reconcile.json b/src/i18n/locales/en/reconcile.json index 6987b74..8e2adf9 100644 --- a/src/i18n/locales/en/reconcile.json +++ b/src/i18n/locales/en/reconcile.json @@ -27,6 +27,7 @@ "playerAllPlayersHint": "If no player is selected, the reconcile job will cover all players in the chosen date range.", "createSummaryAll": "A manual reconcile will run for all players from {{from}} to {{to}}.", "createSummaryPlayer": "A manual reconcile will run for player {{player}} from {{from}} to {{to}}.", + "createSummaryPending": "Choose a complete reconcile date range before creating a job.", "jobsTitle": "Reconcile jobs", "jobsDesc": "Use the action on the right to open paginated item details.", "refresh": "Refresh", diff --git a/src/i18n/locales/en/settlementCenter.json b/src/i18n/locales/en/settlementCenter.json index 5270cb0..61fb005 100644 --- a/src/i18n/locales/en/settlementCenter.json +++ b/src/i18n/locales/en/settlementCenter.json @@ -1,5 +1,50 @@ { "title": "Settlement center", + "subtitle": "Period close, bill confirm, and payments", + "subtitleList": "Period list: open/close periods; summary columns included — use row actions for bills and bet ledger.", + "period": { + "title": "Period", + "statusCompleted": "Completed", + "pipelineShare": "{{count}} ledger entries", + "billTodo": "Pending {{p}} · Awaiting {{a}}", + "openTitle": "Open period", + "openBtn": "Open period", + "closeNeedLedger": "No ledger activity yet. Complete draw settlement first.", + "closeDialogTitle": "Close period", + "closeDialogDesc": "Summarize {{range}} and generate bills.", + "closeDialogShare": "{{count}} ledger entries", + "closeDialogUnsettled": "{{count}} tickets still unsettled", + "closeDialogIrreversible": "Cannot undo. Use adjustments or reversals to fix errors.", + "closeDialogConfirm": "Close period" + }, + "periodDetail": { + "back": "Back to periods", + "notFound": "Period not found or site changed. Go back to the list." + }, + "periodTable": { + "title": "Periods", + "statusFilter": "Status", + "range": "Period", + "ledgerCount": "Ledger", + "pending": "Pending", + "awaiting": "Awaiting", + "shareLedger": "Share ledger", + "gameWinLoss": "Win/loss total", + "platformWinLoss": "Platform win/loss", + "agentWinLoss": "Agent win/loss", + "basicRebate": "Rebate total", + "unsettledTickets": "Unsettled tickets", + "openReportHint": "Open period: share/win-loss from in-period ledger; bill count updates after close.", + "viewDetail": "View details", + "close": "Close", + "closeNow": "Close now", + "hasOpen": "Period {{range}} is open. Close it before opening a new one.", + "emptyOpenHint": "No periods yet. Click Open period in the toolbar.", + "emptyReadOnly": "No period records.", + "emptyFiltered": "No rows match the filter. Reset filters.", + "emptyFilteredOpen": "The open period is hidden by the filter. Choose All or Open.", + "readOnlyHint": "Bound agent accounts cannot open or close site periods." + }, "header": { "subtitle": "Credit-line settlement", "statusRunning": "Period open", @@ -9,18 +54,16 @@ "subnav": { "label": "Settlement center navigation" }, + "workbench": { + "viewPeriod": "Period", + "closePreset": "Close · {{label}}", + "closeNoData": "Close failed: no share ledger in period. Run credit game settlement first.", + "openPeriodPipeline": "Open {{range}} · {{share}} share entries" + }, "nav": { - "aria": "Settlement center navigation", - "group": { - "hub": "Workbench", - "finance": "Finance", - "ledger": "Ledger", - "bills": "Bills" - }, - "overview": "Overview", "periods": "Periods", - "ledger": "Account ledger", "bills": "Bills", + "ledger": "Account ledger", "creditLedger": "Credit ledger", "playerBills": "Player bills", "agentBills": "Agent bills", @@ -33,6 +76,7 @@ }, "filters": { "period": "Period", + "statusAll": "All", "allPeriods": "All periods", "statusOpen": "Open", "statusClosed": "Closed", @@ -54,7 +98,9 @@ "badDebtIntro": "Bad debt write-off entries linked to original bills." }, "creditLedger": { - "intro": "In-period credit holds, bill payments, adjustments, and bad debt. Use ⋯ on a row to confirm, record payment, adjust, reverse, or write off (when a bill is linked).", + "periodIntro": "Credit-line bets in this period: bet hold and draw settlement (merged per ticket).", + "emptyPeriod": "No bet ledger entries in this period. Ensure credit players placed bets and draws were settled.", + "intro": "Ledger entries for the selected period.", "columns": { "txn": "Txn ID", "player": "Player", @@ -83,9 +129,16 @@ "reason": { "payment_record": "Bill payment", "bet_hold": "Bet hold", + "game_settlement": "Draw settlement", + "game_settlement_win": "Draw settlement credit", "bet_hold_release": "Hold release", - "game_settlement_loss": "Draw settlement", - "settlement_confirm": "Period confirm" + "game_settlement_loss": "Draw settlement debit", + "settlement_payout": "Settlement payout", + "settlement_confirm": "Period confirm", + "adjustment": "Adjustment", + "reversal": "Reversal", + "bad_debt": "Bad debt", + "share_ledger": "Share ledger" } }, "columns": { @@ -132,6 +185,51 @@ "viewBill": "View bill", "billDetail": "Bill detail" }, + "billDisplay": { + "settlementFlow": "Who pays whom", + "settlementAmount": "Settlement amount", + "pays": "Pays", + "paysShort": "Pays", + "howAmountWorks": "How the amount is calculated", + "playerBreakdownIntro": "Players settle only with their direct agent: net = win/loss − rebate.", + "agentBreakdownIntro": "Agents settle only with their upline: net = team net − own share profit.", + "playerGross": "Game win/loss", + "playerLostHint": "Player lost; owes agent", + "playerWonHint": "Player won; agent owes player", + "playerNet": "Player net payable", + "playerNetReceive": "Agent pays player", + "teamGross": "Team game win/loss", + "teamGrossHint": "Includes this agent and all downline players", + "teamGrossShort": "Team", + "playerGrossShort": "Player", + "teamRebate": "Team rebate", + "teamNet": "Team net", + "rebate": "Rebate", + "agentShareKeep": "{{agent}} share kept", + "agentShareKeepHint": "Profit retained at this tier by share ratio", + "agentNet": "{{agent}} pays upline", + "agentNetReceive": "Upline pays {{agent}}", + "billOwner": "Bill owner", + "billCounterparty": "Counterparty", + "unpaidPendingConfirm": "Confirm the bill before recording payment", + "unpaidAwaitingPayment": "Record offline payment", + "fullySettled": "Fully settled this period", + "recordReceiptFrom": "Record receipt ({{payer}} → {{payee}})", + "recordPayoutTo": "Record payout ({{payer}} → {{payee}})", + "rebateAllocationsHint": "How rebate is allocated across agent tiers.", + "payment": "Payment", + "flowHint": { + "playerPayAgent": "Player should settle with the direct agent", + "agentPayPlayer": "Direct agent should settle with the player", + "agentPayUpstream": "This agent should settle with upline / platform", + "upstreamPayAgent": "Upline / platform should settle with this agent", + "adjustment": "Adjustment settles separately and keeps the original bill relation", + "badDebt": "Write off unpaid amount and archive it as bad debt", + "reversal": "Reverse the original bill impact according to reversal rules", + "generic": "Apply payment or adjustment based on this bill relation" + }, + "hierarchyHint": "One period creates multiple bills: players pay their agent first; each agent keeps share profit and remits the rest upline. Gross win/loss may match across rows while settlement amounts step down." + }, "ledgerPanel": { "search": "Search", "searchBtn": "Search", @@ -144,18 +242,34 @@ "optional": "Optional", "billStatus": "Bill status", "dateRange": "Date range", - "rowPosted": "Posted", - "category": { - "all": "All", - "credit": "Credit holds", - "payment": "Payments", - "adjustment": "Adjust / reverse", - "badDebt": "Bad debt", - "actionable": "Needs action" - } + "rowPosted": "Posted" }, "billsPanel": { "intro": "Share bills after period close. Filter by type or status; open detail to confirm or record payment.", + "hierarchyHint": "One period creates multiple bills: players pay their agent first; each agent keeps share profit and remits the rest upline. Gross win/loss may match across rows while settlement amounts step down.", + "quickFilter": { + "title": "Which settlement layer do you want to review", + "desc": "Choose player or agent settlement first, then narrow by status or bill id.", + "allTitle": "All bills", + "allHint": "View player, agent, adjustment, and bad debt bills together", + "playerTitle": "Player settlement", + "playerHint": "Credit settlement between players and their direct agent", + "agentTitle": "Agent settlement", + "agentHint": "Tier settlement between agents and their upline / platform" + }, + "activeHint": { + "all": "Current focus: all settlement documents in this period", + "player": "Current focus: credit settlement between players and direct agents", + "agent": "Current focus: payments between agents and their upline / platform" + }, + "layer": { + "player": "Player vs direct agent", + "agent": "Agent vs upline / platform", + "adjustment": "Settlement adjustment", + "badDebt": "Bad debt archive", + "reversal": "Historical bill reversal", + "generic": "Settlement supporting document" + }, "category": { "all": "All", "player": "Player bills", @@ -179,12 +293,12 @@ "badDebt": { "title": "Bad debt" } }, "empty": { - "noSite": "Select an integration site.", - "noPeriods": "Open and close a period under Periods first.", + "noSite": "Select a site.", + "noPeriods": "Close the current period first.", "noClosed": "Close a period to generate bills.", - "noBadDebt": "No bad debt write-offs yet.", - "noCreditLedger": "No credit ledger rows in this period. Check credit players placed bets and the period date range.", - "billsNeedClose": "Share bills appear after period close. If credit ledger has rows but bills are empty, settle draws then close the period." + "noBadDebt": "No bad debt records.", + "noCreditLedger": "No ledger entries in this period.", + "billsNeedClose": "Bills are created after period close." }, "periods": { "loadFailed": "Failed to load periods" diff --git a/src/i18n/locales/en/wallet.json b/src/i18n/locales/en/wallet.json index 732442f..9473a48 100644 --- a/src/i18n/locales/en/wallet.json +++ b/src/i18n/locales/en/wallet.json @@ -3,9 +3,9 @@ "subnavLabel": "Wallet sub pages", "subnavTransactions": "Main-site wallet txns", "subnavTransferOrders": "Main-site transfers", - "scopeHint": "This area is for main-site wallet mode (wallet txns and transfers). For credit-line bet holds and settlement entries, see", - "scopeHintSettlementLink": "Settlement center → Credit ledger", - "scopeHintSettlement": "Settlement center → Credit ledger", + "scopeHint": "This area is for main-site wallet mode (wallet txns and transfers). For credit-line period settlement, see", + "scopeHintSettlementLink": "Settlement center", + "scopeHintSettlement": "Settlement center", "ledgerChannel": "Ledger", "ledgerCredit": "Credit ledger", "ledgerWallet": "Wallet txn", diff --git a/src/i18n/locales/ne/config.json b/src/i18n/locales/ne/config.json index 342cd2b..9fe9572 100644 --- a/src/i18n/locales/ne/config.json +++ b/src/i18n/locales/ne/config.json @@ -17,6 +17,7 @@ "rulesPlaysTitle": "खेल नियम", "rulesOddsTitle": "बाधा र रिबेट", "rulesOddsDescription": "बाधा म्याट्रिक्स र रिबेट दर एउटै पृष्ठमा, एउटै बाधा संस्करण लाइनमा।", + "rulesOddsDescriptionShort": "बायाँबाट खेल छान्नुहोस्, दायाँबाट बाधा र रिबेट सम्पादन गर्नुहोस्, त्यसपछि सेभ र प्रकाशन गर्नुहोस्।", "riskCapTitle": "जोखिम क्याप संस्करण" }, "hub": { @@ -394,6 +395,16 @@ "oddsConfig": "बाधा सेटिङ" }, "currentSelection": "हालको छनोट: {{category}} / {{play}}", + "playSelectPlaceholder": "खेल प्रकार छान्नुहोस्", + "readOnlyBanner": "यो संस्करण पढ्न मात्र हो। बाधा र रिबेट सम्पादन गर्न ड्राफ्ट बनाउनुहोस्।", + "table": { + "prizeScope": "पुरस्कार दायरा", + "multiplier": "बाधा गुणक" + }, + "draftBar": { + "unsaved": "नसेभ परिवर्तनहरू", + "saved": "परिवर्तन स्थानीय ड्राफ्टमा राखिएको छ" + }, "playGroups": { "bigSmall": "ठूलो / सानो", "combo4": "4D स्थिति", @@ -403,10 +414,13 @@ }, "summary": { "title": "सारांश", - "version": "संस्करण", + "contextTitle": "संस्करण र सुझाव", + "version": "सम्पादन संस्करण", + "activeVersion": "सक्रिय संस्करण", "statusLabel": "स्थिति", "readOnlyTag": "पढ्न मात्र", "readOnlyHint": "यो संस्करण पढ्न मात्र हो। परिवर्तन गर्न ड्राफ्ट बनाउनुहोस्।", + "draftHint": "प्रकाशन अघि ड्राफ्ट सेभ गर्नुहोस्; प्रकाशनले नयाँ टिकटमा मात्र असर गर्छ।", "activeHint": "यो संस्करण सक्रिय छ; नयाँ टिकट यही सेटिङ प्रयोग गर्छ।" }, "tabs": { diff --git a/src/i18n/locales/ne/reconcile.json b/src/i18n/locales/ne/reconcile.json index dc28da4..0e0243f 100644 --- a/src/i18n/locales/ne/reconcile.json +++ b/src/i18n/locales/ne/reconcile.json @@ -23,6 +23,7 @@ "playerAllPlayersHint": "खेलाडी नछानेमा, छनोट गरिएको मिति दायराभित्र सबै खेलाडीका लागि मिलान चलाइनेछ।", "createSummaryAll": "{{from}} देखि {{to}} सम्म सबै खेलाडीका लागि म्यानुअल मिलान चलाइनेछ।", "createSummaryPlayer": "खेलाडी {{player}} का लागि {{from}} देखि {{to}} सम्म म्यानुअल मिलान चलाइनेछ।", + "createSummaryPending": "कार्य सिर्जना गर्नु अघि पूरा मिलान मिति दायरा छान्नुहोस्।", "jobsTitle": "मिलान कार्यहरू", "jobsDesc": "दायाँपट्टिको कार्यबाट विवरण खोल्नुहोस्।", "refresh": "रिफ्रेस", diff --git a/src/i18n/locales/zh/agents.json b/src/i18n/locales/zh/agents.json index 43b0d27..6e6411b 100644 --- a/src/i18n/locales/zh/agents.json +++ b/src/i18n/locales/zh/agents.json @@ -164,7 +164,7 @@ "periodPlaceholder": "选择账期", "allPeriods": "全部账期", "filteredByPeriodRange": "账期 {{range}} 的账单", - "emptyNoPeriodsManage": "尚无账期与账单。请在下方「账期管理」点「本周」开期,到期后关账,账单会自动出现在这里。", + "emptyNoPeriodsManage": "尚无账单。请在结算中心完成「关账出账」后,账单会出现在此处。", "emptyNoPeriodsAgent": "尚无账单。账期由上级或平台关账后自动生成,无需您手动筛选时间。", "emptyNoClosed": "当前没有已关账的账期,账单尚未生成。请等待负责人关账后再查看。", "typePlayer": "玩家账单", @@ -273,7 +273,7 @@ "range": "账期", "statusOpen": "进行中", "statusClosed": "已关账", - "empty": "尚无账期。点「本周」等快捷账期后开期,到期后在此关账。", + "empty": "尚无账期,请选择快捷账期开期。", "start": "开始", "end": "结束", "status": "状态", diff --git a/src/i18n/locales/zh/config.json b/src/i18n/locales/zh/config.json index b57ee44..40c2dc6 100644 --- a/src/i18n/locales/zh/config.json +++ b/src/i18n/locales/zh/config.json @@ -17,6 +17,7 @@ "rulesPlaysTitle": "投注规则", "rulesOddsTitle": "赔率与回水", "rulesOddsDescription": "赔率矩阵与回水比例在同一页维护,共用赔率版本线。", + "rulesOddsDescriptionShort": "左侧选玩法,右侧改赔率与回水;修改后记得保存草稿并发布。", "riskCapTitle": "限额版本" }, "hub": { @@ -420,6 +421,16 @@ "oddsConfig": "赔率配置" }, "currentSelection": "当前选择:{{category}} / {{play}}", + "playSelectPlaceholder": "选择玩法", + "readOnlyBanner": "当前版本只读,需先创建草稿才能修改赔率与回水。", + "table": { + "prizeScope": "奖级范围", + "multiplier": "赔率倍数" + }, + "draftBar": { + "unsaved": "有未保存的修改", + "saved": "修改已同步到本地草稿" + }, "playGroups": { "bigSmall": "大小类", "combo4": "组合类", @@ -429,10 +440,13 @@ }, "summary": { "title": "配置摘要", - "version": "版本", + "contextTitle": "版本与提示", + "version": "当前编辑版本", + "activeVersion": "线上生效版本", "statusLabel": "状态", "readOnlyTag": "只读", "readOnlyHint": "当前为只读版本,如需修改请先创建草稿。", + "draftHint": "草稿中的修改需保存后才会写入版本;发布后对后续新注单生效。", "activeHint": "当前版本已生效,新注单将按此配置计算。" }, "tabs": { diff --git a/src/i18n/locales/zh/reconcile.json b/src/i18n/locales/zh/reconcile.json index 3260f12..673d42d 100644 --- a/src/i18n/locales/zh/reconcile.json +++ b/src/i18n/locales/zh/reconcile.json @@ -27,6 +27,7 @@ "playerAllPlayersHint": "不选择玩家时,会按日期范围对全量玩家做一次人工对账。", "createSummaryAll": "将对 {{from}} 至 {{to}} 的全量玩家发起人工对账。", "createSummaryPlayer": "将对玩家 {{player}} 在 {{from}} 至 {{to}} 的数据发起人工对账。", + "createSummaryPending": "请选择完整的对账日期范围后,再创建任务。", "jobsTitle": "对账任务", "jobsDesc": "在右侧操作中查看差异明细与分页。", "refresh": "刷新", diff --git a/src/i18n/locales/zh/settlementCenter.json b/src/i18n/locales/zh/settlementCenter.json index 35badb6..0452cb3 100644 --- a/src/i18n/locales/zh/settlementCenter.json +++ b/src/i18n/locales/zh/settlementCenter.json @@ -1,26 +1,55 @@ { "title": "结算中心", - "header": { - "subtitle": "信用占成账务", - "statusRunning": "账期进行中", - "statusIdle": "等待开期", - "statusCompleted": "账期已结清" + "subtitle": "账期关账、账单确认与收付登记", + "subtitleList": "账期列表:开账、关账;列表已含账期汇总,行内「查看详情」进入账单与下注流水。", + "period": { + "title": "账期", + "statusCompleted": "已结清", + "pipelineShare": "流水 {{count}} 笔", + "billTodo": "待确认 {{p}} · 待收付 {{a}}", + "openTitle": "开账", + "openBtn": "开账", + "closeNeedLedger": "本期暂无流水,请先完成开奖结算。", + "closeDialogTitle": "确认关账", + "closeDialogDesc": "将汇总 {{range}} 内的流水并生成账单。", + "closeDialogShare": "流水 {{count}} 笔", + "closeDialogEmpty": "本期暂无占成流水,关账后不会生成账单。", + "closeDialogUnsettled": "仍有 {{count}} 笔注单未结算", + "closeDialogIrreversible": "关账后不可撤销,差错请通过调账或冲正处理。", + "closeDialogConfirm": "确认关账" }, - "subnav": { - "label": "结算中心导航" + "periodDetail": { + "back": "返回账期列表", + "notFound": "账期不存在或已切换站点,请返回列表。" + }, + "periodTable": { + "title": "账期管理", + "statusFilter": "状态", + "range": "账期", + "ledgerCount": "流水", + "pending": "待确认", + "awaiting": "待收付", + "shareLedger": "占成流水", + "gameWinLoss": "输赢合计", + "platformWinLoss": "平台输赢", + "agentWinLoss": "代理输赢", + "basicRebate": "退水合计", + "unsettledTickets": "未结算注单", + "openReportHint": "进行中账期:占成/输赢来自账期内流水;账单数在关账后更新。", + "viewDetail": "查看详情", + "close": "关账", + "closeNow": "立即关账", + "hasOpen": "已有进行中账期 {{range}},须先关账才能开新期。", + "emptyOpenHint": "暂无账期,请点击工具栏「开账」创建。", + "emptyReadOnly": "暂无账期记录。", + "emptyFiltered": "筛选结果为空,请重置筛选。", + "emptyFilteredOpen": "当前筛选未包含进行中的账期,请选「全部」或「进行中」。", + "readOnlyHint": "绑定代理账号不可开/关账期,仅可查看与收付。" }, "nav": { - "aria": "结算中心导航", - "group": { - "hub": "工作台", - "finance": "账务", - "ledger": "账务流水", - "bills": "账单管理" - }, - "overview": "概览", - "periods": "账期管理", - "ledger": "账务流水", + "periods": "账期", "bills": "账单", + "ledger": "账务流水", "creditLedger": "信用流水", "playerBills": "玩家账单", "agentBills": "代理账单", @@ -33,6 +62,7 @@ }, "filters": { "period": "账期范围", + "statusAll": "全部", "allPeriods": "全部账期", "statusOpen": "进行中", "statusClosed": "已关账", @@ -54,7 +84,9 @@ "badDebtIntro": "坏账核销产生的调账流水,关联原账单。" }, "creditLedger": { - "intro": "账期内信用占用、账单收付、调账与坏账等流水;行内「⋯」可确认账单、登记收付、调账、冲正或坏账(需关联账单)。", + "periodIntro": "待开奖显示「下注冻结」(非扣款);已开奖每注单仅一条「开奖结算」输赢,不会与冻结重复出现。", + "emptyPeriod": "本账期暂无下注流水。请确认信用盘玩家已在账期内下注并完成开奖结算。", + "intro": "查询账期内的额度变动、收付与调账记录。", "columns": { "txn": "流水号", "player": "玩家", @@ -82,15 +114,30 @@ }, "reason": { "payment_record": "账单收付", - "bet_hold": "下注占用", + "bet_hold": "下注冻结", + "freezeAmount": "冻结 {{amount}}", + "game_settlement": "开奖结算", + "game_settlement_win": "开奖结算入账", "bet_hold_release": "占用释放", "game_settlement_loss": "开奖结算扣款", - "settlement_confirm": "账期结算确认" + "settlement_payout": "结算收付入账", + "settlement_confirm": "账期结算确认", + "adjustment": "补差", + "reversal": "冲正", + "bad_debt": "坏账核销", + "share_ledger": "占成流水" } }, "columns": { + "billId": "账单 ID", "period": "账期", "type": "类型", + "playerAccount": "玩家账号", + "playerId": "玩家 ID", + "directAgent": "直属代理", + "superiorAgent": "上级代理", + "play": "玩法", + "drawNo": "期号", "owner": "本方", "counterparty": "对方", "gross": "输赢", @@ -132,6 +179,51 @@ "viewBill": "查看账单", "billDetail": "账单详情" }, + "billDisplay": { + "settlementFlow": "谁付谁", + "settlementAmount": "结算金额", + "pays": "应付", + "paysShort": "应付", + "howAmountWorks": "金额怎么来的", + "playerBreakdownIntro": "玩家只与直属代理结算,净额 = 输赢 − 回水。", + "agentBreakdownIntro": "代理只与直属上级结算,净额 = 团队净额 − 本级占成。", + "playerGross": "游戏输赢", + "playerLostHint": "玩家输了,应付代理", + "playerWonHint": "玩家赢了,代理应付玩家", + "playerNet": "玩家应付净额", + "playerNetReceive": "代理应付玩家", + "teamGross": "团队游戏输赢", + "teamGrossHint": "含本级及下级玩家的合计", + "teamGrossShort": "团队", + "playerGrossShort": "玩家", + "teamRebate": "团队回水", + "teamNet": "团队净额", + "rebate": "回水", + "agentShareKeep": "{{agent}} 本级占成", + "agentShareKeepHint": "本级按占成比例留下的利润", + "agentNet": "{{agent}} 应付上级", + "agentNetReceive": "上级应付 {{agent}}", + "billOwner": "账单主体", + "billCounterparty": "结算对手", + "unpaidPendingConfirm": "确认账单后可登记收付", + "unpaidAwaitingPayment": "请登记线下收付", + "fullySettled": "本期已结清", + "recordReceiptFrom": "登记收款({{payer}} 付给 {{payee}})", + "recordPayoutTo": "登记付款({{payer}} 付给 {{payee}})", + "rebateAllocationsHint": "各层级代理对回水的承担明细。", + "payment": "收付", + "flowHint": { + "playerPayAgent": "玩家应向直属代理结算", + "agentPayPlayer": "直属代理应向玩家结算", + "agentPayUpstream": "本级代理应向上级 / 平台结算", + "upstreamPayAgent": "上级 / 平台应向本级代理结算", + "adjustment": "补差单独结转,不改变原账单主体关系", + "badDebt": "核销未结金额,并生成坏账归档记录", + "reversal": "冲正原账单影响,按冲正规则回退", + "generic": "按账单结算关系执行收付或调账" + }, + "hierarchyHint": "同一账期会生成多笔账单:玩家先与直属代理结,代理扣除本级占成后再向上级缴纳。因此「输赢」可能相同,但「结算金额」会逐级减少。" + }, "ledgerPanel": { "search": "搜索", "searchBtn": "搜索", @@ -144,30 +236,67 @@ "optional": "可选", "billStatus": "账单状态", "dateRange": "时间范围", - "rowPosted": "已记账", - "category": { - "all": "全部", - "credit": "信用占用", - "payment": "收付", - "adjustment": "调账 / 冲正", - "badDebt": "坏账", - "actionable": "待操作" - } + "rowPosted": "已记账" }, "billsPanel": { - "intro": "关账后生成的占成账单;可按类型与状态筛选,打开详情进行确认与收付。", + "billId": "账单 ID", + "ownerKeyword": "本方 / 对方", + "ownerKeywordPh": "玩家账号、代理名称", + "status": "账单状态", + "billType": "账单类型", + "filterAll": "全部状态", + "filterAllTypes": "全部类型", + "filterAdjustment": "调账 / 冲正", + "optional": "可选", + "searchBtn": "搜索", + "reset": "重置", + "refresh": "刷新", + "clientFilterHint": "本方筛选:显示 {{shown}} / {{total}} 条", "category": { - "all": "全部", "player": "玩家账单", "agent": "代理账单", "pendingConfirm": "待确认", "awaitingPayment": "待收付" - } + }, + "quickFilter": { + "title": "当前想看哪一层结算", + "desc": "先区分结玩家还是结代理,再做状态和账单检索会更直观。", + "allTitle": "全部账单", + "allHint": "同时查看玩家、代理、补差和坏账单据", + "playerTitle": "结玩家", + "playerHint": "玩家与直属代理之间的信用结算", + "agentTitle": "结代理", + "agentHint": "代理与上级 / 平台之间的层级结算" + }, + "activeHint": { + "all": "当前聚焦:账期内全部结算单据", + "player": "当前聚焦:玩家与直属代理的信用结算", + "agent": "当前聚焦:代理向上级 / 平台缴纳或收取结算" + }, + "layer": { + "player": "玩家与直属代理结算", + "agent": "代理与上级 / 平台结算", + "adjustment": "结算差异调账", + "badDebt": "坏账核销归档", + "reversal": "历史账单冲正", + "generic": "结算辅助单据" + }, + "rowHint": { + "playerOwner": "玩家主体", + "agentOwner": "本级代理", + "adjustmentOwner": "调账归属方", + "badDebtOwner": "坏账归属方", + "reversalOwner": "冲正归属方", + "playerUpline": "直属代理的上级", + "agentUpline": "本单结算上级" + }, + "hierarchyHint": "同一账期会生成多笔账单:玩家先与直属代理结,代理扣除本级占成后再向上级缴纳。因此「输赢」可能相同,但「结算金额」会逐级减少。" }, "panels": { + "workbench": { "title": "工作台" }, "overview": { "title": "结算概览" }, "ledger": { "title": "账务流水" }, - "bills": { "title": "账单" }, + "bills": { "title": "全部账单" }, "creditLedger": { "title": "信用流水" }, "playerBills": { "title": "玩家账单" }, "agentBills": { "title": "代理账单" }, @@ -178,13 +307,17 @@ "reports": { "title": "账期报表" }, "badDebt": { "title": "坏账核销" } }, + "billsPanel": { + "emptyFiltered": "当前筛选下暂无账单,请改为「全部状态」或重置筛选。", + "emptyClosed": "本期已关账但暂无账单。常见原因:账期内无信用盘玩家的已结算注单,或占成流水不在本账期时间范围内。" + }, "empty": { - "noSite": "请选择接入站点。", - "noPeriods": "请先在「账期管理」开期并关账。", + "noSite": "请选择站点。", + "noPeriods": "请先关账当前账期。", "noClosed": "请先关账生成账单。", - "noBadDebt": "暂无坏账核销记录。", - "noCreditLedger": "所选账期内暂无信用流水。请确认信用盘玩家已下注且账期时间范围正确。", - "billsNeedClose": "占成账单须先关账才会出现;若上方「信用流水」有数据而账单为空,请完成开奖结算后执行关账。" + "noBadDebt": "暂无坏账记录。", + "noCreditLedger": "该账期内暂无流水。", + "billsNeedClose": "账单在关账后生成。请返回账期列表,对本期执行「关账」后再查看。" }, "periods": { "loadFailed": "账期列表加载失败" diff --git a/src/i18n/locales/zh/wallet.json b/src/i18n/locales/zh/wallet.json index bd73df0..04bcb68 100644 --- a/src/i18n/locales/zh/wallet.json +++ b/src/i18n/locales/zh/wallet.json @@ -3,9 +3,9 @@ "subnavLabel": "钱包子页", "subnavTransactions": "主站钱包流水", "subnavTransferOrders": "主站转账单", - "scopeHint": "本模块为主站钱包模式:钱包流水与主站转账单。信用盘玩家的下注占用、结算记账请查看", - "scopeHintSettlementLink": "结算中心 → 信用流水", - "scopeHintSettlement": "结算中心 → 信用流水", + "scopeHint": "本模块为主站钱包模式:钱包流水与主站转账单。信用盘玩家的账期结账请查看", + "scopeHintSettlementLink": "结算中心", + "scopeHintSettlement": "结算中心", "ledgerChannel": "账本", "ledgerCredit": "信用流水", "ledgerWallet": "钱包流水", diff --git a/src/lib/admin-select-display.ts b/src/lib/admin-select-display.ts new file mode 100644 index 0000000..195ea72 --- /dev/null +++ b/src/lib/admin-select-display.ts @@ -0,0 +1,51 @@ +/** Base UI Select 会直接显示 `value`;筛选用哨兵时需手动映射展示文案。 */ +export const ADMIN_SELECT_FILTER_ALL = "__all__"; + +export function isAdminSelectFilterAll(raw: unknown): boolean { + const v = raw == null ? "" : String(raw); + return v === "" || v === ADMIN_SELECT_FILTER_ALL; +} + +export function adminSelectFilterLabel( + raw: unknown, + allLabel: string, + resolveOptionLabel: (value: string) => string, +): string { + const v = raw == null ? "" : String(raw); + if (isAdminSelectFilterAll(v)) { + return allLabel; + } + return resolveOptionLabel(v); +} + +export type AdminSiteOption = { code: string; name?: string | null }; + +export function adminSiteSelectLabel( + raw: unknown, + sites: readonly AdminSiteOption[], + allLabel: string, +): string { + return adminSelectFilterLabel(raw, allLabel, (code) => { + const site = sites.find((s) => s.code === code); + if (!site) { + return code; + } + return site.name ? `${site.name} (${site.code})` : site.code; + }); +} + +export function adminSiteCodeLabel( + raw: unknown, + sites: readonly AdminSiteOption[], + placeholder: string, +): string { + const v = raw == null ? "" : String(raw); + if (v === "") { + return placeholder; + } + const site = sites.find((s) => s.code === v); + if (!site) { + return v; + } + return site.name ? `${site.name} (${site.code})` : site.code; +} diff --git a/src/lib/admin-site-display.ts b/src/lib/admin-site-display.ts new file mode 100644 index 0000000..062f373 --- /dev/null +++ b/src/lib/admin-site-display.ts @@ -0,0 +1,13 @@ +/** 后台展示用站点名:优先中文名,不暴露内部 code。 */ +export function formatAdminSiteLabel( + name?: string | null, + code?: string | null, +): string { + const trimmedName = name?.trim(); + if (trimmedName) { + return trimmedName; + } + + const trimmedCode = code?.trim(); + return trimmedCode ?? "—"; +} diff --git a/src/lib/admin-status-tone.ts b/src/lib/admin-status-tone.ts index d025f5e..854c11c 100644 --- a/src/lib/admin-status-tone.ts +++ b/src/lib/admin-status-tone.ts @@ -58,6 +58,11 @@ const STATUS_TONE_MAP: Record = { completed: "success", failed: "danger", + // 代理账期账单 + confirmed: "warning", + partial_paid: "warning", + overdue: "danger", + // 注单 pending_confirm: "info", partial_pending_confirm: "warning", diff --git a/src/modules/_config/admin-nav-icons.tsx b/src/modules/_config/admin-nav-icons.tsx index b4fd4d9..82b1a98 100644 --- a/src/modules/_config/admin-nav-icons.tsx +++ b/src/modules/_config/admin-nav-icons.tsx @@ -2,20 +2,25 @@ import type { LucideIcon } from "lucide-react"; import { CalendarClock, CircleDollarSign, + ClipboardList, + Coins, FileSpreadsheet, Globe, + KeyRound, Landmark, LayoutDashboard, LogIn, Network, + Percent, Scale, ScrollText, Receipt, Settings, ShieldAlert, - ShieldCheck, SlidersHorizontal, Ticket, + Trophy, + UserCog, Users, Wallet, } from "lucide-react"; @@ -29,9 +34,9 @@ export const adminNavIconBySegment: Record agents: Network, players: Users, draws: CalendarClock, - rules_plays: SlidersHorizontal, - rules_odds: SlidersHorizontal, - jackpot: CircleDollarSign, + rules_plays: ClipboardList, + rules_odds: Percent, + jackpot: Trophy, risk_cap: ShieldAlert, tickets: Ticket, wallet: Wallet, @@ -41,9 +46,9 @@ export const adminNavIconBySegment: Record settlement_center: Receipt, reconcile: Scale, audit: ScrollText, - admin_users: ShieldCheck, - admin_roles: ShieldCheck, - currencies: CircleDollarSign, + admin_users: UserCog, + admin_roles: KeyRound, + currencies: Coins, integration: Globe, settings: Settings, }; diff --git a/src/modules/_config/admin-nav.ts b/src/modules/_config/admin-nav.ts index 63e52d3..7a866f3 100644 --- a/src/modules/_config/admin-nav.ts +++ b/src/modules/_config/admin-nav.ts @@ -37,6 +37,7 @@ export type AdminNavItem = { segment: AdminNavSegment; nav_group?: AdminNavGroup; platform_only?: boolean; + agent_hidden?: boolean; activeMatchPrefix?: string; requiredAny?: readonly string[]; }; diff --git a/src/modules/agents/agent-line-detail-panel.tsx b/src/modules/agents/agent-line-detail-panel.tsx index 6694d16..90e5147 100644 --- a/src/modules/agents/agent-line-detail-panel.tsx +++ b/src/modules/agents/agent-line-detail-panel.tsx @@ -4,6 +4,7 @@ import type { ComponentType } from "react"; import { ChevronRight, Network, Pencil, Plus, Trash2, Users } from "lucide-react"; import { useTranslation } from "react-i18next"; +import { AdminSubnav, AdminSubnavButton } from "@/components/admin/admin-subnav"; import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state"; import { AdminStatusBadge } from "@/components/admin/admin-status-badge"; import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu"; @@ -218,19 +219,23 @@ export function AgentLineDetailPanel({
-
+ {tabs .filter((tab) => tab.visible) .map((tab) => ( - onDetailTabChange(tab.key)} - label={tab.label} count={tab.count} - /> + > + {tab.label} + ))} -
+
{detailTab === "overview" ? ( @@ -569,7 +574,7 @@ function DownlineTable({ {child.email ?? "—"} - {summary ? `${ratioToPercentUi(summary.total_share_rate)}%` : "—"} + {summary ? `${summary.total_share_rate ?? 0}%` : "—"} {summary ? formatCredit(summary.credit_limit) : "—"} @@ -660,40 +665,3 @@ function MetricCard({
); } - -function TabButton({ - active, - onClick, - label, - count, -}: { - active: boolean; - onClick: () => void; - label: string; - count?: number; -}): React.ReactElement { - return ( - - ); -} diff --git a/src/modules/agents/agent-line-provision-wizard.tsx b/src/modules/agents/agent-line-provision-wizard.tsx index abfbff2..3c17243 100644 --- a/src/modules/agents/agent-line-provision-wizard.tsx +++ b/src/modules/agents/agent-line-provision-wizard.tsx @@ -21,6 +21,7 @@ import { import { Switch } from "@/components/ui/switch"; import { useAsyncEffect } from "@/hooks/use-async-effect"; import { percentUiToRatio } from "@/lib/admin-rate-percent"; +import { adminSiteCodeLabel } from "@/lib/admin-select-display"; import { LotteryApiBizError } from "@/types/api/errors"; import type { AdminIntegrationSiteRow } from "@/types/api/admin-integration-site"; @@ -128,19 +129,23 @@ export function AgentLineProvisionWizard(): React.ReactElement { disabled={sitesLoading || unboundSites.length === 0} > - + {(v) => + adminSiteCodeLabel( + v, + unboundSites, + sitesLoading + ? t("common:loading", { defaultValue: "加载中…" }) + : unboundSites.length === 0 + ? t("agents:lineProvision.noUnboundSite", { + defaultValue: "暂无未绑定一级代理的站点", + }) + : t("agents:lineProvision.siteCodePlaceholder", { + defaultValue: "选择站点", + }), + ) } - /> + {unboundSites.map((site) => ( @@ -248,7 +253,15 @@ export function AgentLineProvisionWizard(): React.ReactElement { } > - + + {(v) => + v === "daily" + ? t("agents:profile.cycleDaily", { defaultValue: "日结" }) + : v === "monthly" + ? t("agents:profile.cycleMonthly", { defaultValue: "月结" }) + : t("agents:profile.cycleWeekly", { defaultValue: "周结" }) + } + diff --git a/src/modules/agents/agent-profile-fields.tsx b/src/modules/agents/agent-profile-fields.tsx index 1df8ac4..c645980 100644 --- a/src/modules/agents/agent-profile-fields.tsx +++ b/src/modules/agents/agent-profile-fields.tsx @@ -122,7 +122,9 @@ export function AgentProfileFields({ >
onShareRateChange(e.target.value)} /> + {parentCaps && shareRate ? ( +

+ {t("profile.actualShareRate", { + defaultValue: "实际占成 {{rate}}%", + rate: Number((Number(parentCaps.total_share_rate) * Number(shareRate) / 100).toFixed(2)), + })} +

+ ) : null}
+ + ); +} diff --git a/src/modules/config/doc/odds-config-play-nav.tsx b/src/modules/config/doc/odds-config-play-nav.tsx new file mode 100644 index 0000000..d8d90bb --- /dev/null +++ b/src/modules/config/doc/odds-config-play-nav.tsx @@ -0,0 +1,158 @@ +"use client"; + +import { useTranslation } from "react-i18next"; + +import { ConfigChip, ConfigChipGroup } from "@/modules/config/config-chip-group"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + buildOddsPlayFilterGroups, + filterOddsPlayTypesByCategory, + type OddsCategoryTab, +} from "@/modules/config/doc/odds-play-type-groups"; +import { resolveAdminPlayTypeDisplayName } from "@/lib/admin-play-types"; +import { cn } from "@/lib/utils"; +import type { AdminPlayTypeRow } from "@/types/api/admin-config"; + +const PLAY_SELECT_NONE = "__none__"; + +type OddsConfigPlayNavProps = { + catTab: OddsCategoryTab; + onCatTabChange: (tab: OddsCategoryTab) => void; + onPlayCodeChange: (code: string) => void; + types: AdminPlayTypeRow[]; + resolvedPlayCode: string; + className?: string; +}; + +export function OddsConfigPlayNav({ + catTab, + onCatTabChange, + onPlayCodeChange, + types, + resolvedPlayCode, + className, +}: OddsConfigPlayNavProps) { + const { t, i18n } = useTranslation("config"); + + const catTabs = [ + { id: "all" as const, label: t("odds.tabs.all") }, + { id: "d4" as const, label: "4D" }, + { id: "d3" as const, label: "3D" }, + { id: "d2" as const, label: "2D" }, + ]; + + const filteredTypes = filterOddsPlayTypesByCategory(catTab, types); + const playGroups = buildOddsPlayFilterGroups(catTab, types); + const playSelectValue = resolvedPlayCode || PLAY_SELECT_NONE; + + const playLabel = (code: string): string => { + const row = types.find((item) => item.play_code === code); + return resolveAdminPlayTypeDisplayName(code, i18n.language, row); + }; + + return ( +
+ + {catTabs.map((tab) => ( + onCatTabChange(tab.id)} + > + {tab.label} + + ))} + + + {/* 小屏:下拉快速切换玩法 */} +
+

{t("odds.playType")}

+ +
+ + {/* 大屏:侧栏玩法列表,点选即切换 */} + +
+ ); +} diff --git a/src/modules/config/doc/odds-config-summary-panel.tsx b/src/modules/config/doc/odds-config-summary-panel.tsx index c568956..d143fbd 100644 --- a/src/modules/config/doc/odds-config-summary-panel.tsx +++ b/src/modules/config/doc/odds-config-summary-panel.tsx @@ -5,28 +5,21 @@ import { useTranslation } from "react-i18next"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { ConfigStatusBadge } from "@/modules/config/config-status-badge"; -import { inferRebatePercentFromDimension, rateToPercentUi } from "@/modules/config/doc/odds-rebate-rates"; +import { rateToPercentUi } from "@/modules/config/doc/odds-rebate-rates"; import { prizeScopeLabel, type PrizeScopeCode } from "@/modules/config/doc/prize-scopes"; +import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"; import { cn } from "@/lib/utils"; -import type { AdminPlayTypeRow, OddsItemRow, OddsVersionDetail } from "@/types/api/admin-config"; - -function oddsMultiplierLabel(oddsValue: number): string { - return (oddsValue / 10000).toFixed(4); -} - -type SummaryRow = { - label: string; - value: string; -}; +import type { ConfigVersionSummary, OddsItemRow, OddsVersionDetail } from "@/types/api/admin-config"; type OddsConfigSummaryPanelProps = { - catTabLabel: string; - playLabel: string; + catTabLabel?: string; + playLabel?: string; detail: OddsVersionDetail | null; - draftRows: OddsItemRow[]; - types: AdminPlayTypeRow[]; - scopeRows: Partial>; - playRebatePercent: string; + scopeRows?: Partial>; + playRebatePercent?: string; + activeHead?: ConfigVersionSummary | null; + /** 合并页:仅展示版本与操作提示,不重复主编辑区数值 */ + compact?: boolean; className?: string; }; @@ -34,63 +27,32 @@ export function OddsConfigSummaryPanel({ catTabLabel, playLabel, detail, - draftRows, - types, scopeRows, playRebatePercent, + activeHead = null, + compact = false, className, }: OddsConfigSummaryPanelProps) { const { t } = useTranslation("config"); + const formatDt = useAdminDateTimeFormatter(); const isDraft = detail?.status === "draft"; const isActive = detail?.status === "active"; - - const rows: SummaryRow[] = [ - { label: t("odds.category"), value: catTabLabel }, - { label: t("odds.playType"), value: playLabel || "—" }, - ]; - - for (const scope of ["first", "second", "third", "starter", "consolation"] as PrizeScopeCode[]) { - const row = scopeRows[scope]; - rows.push({ - label: prizeScopeLabel(scope, t), - value: row ? oddsMultiplierLabel(row.odds_value) : "—", - }); - } - - rows.push({ - label: t("odds.rebateRate"), - value: playRebatePercent, - }); - - rows.push( - { - label: t("rebate.fields.d2"), - value: inferRebatePercentFromDimension(2, draftRows, types), - }, - { - label: t("rebate.fields.d3"), - value: inferRebatePercentFromDimension(3, draftRows, types), - }, - { - label: t("rebate.fields.d4"), - value: inferRebatePercentFromDimension(4, draftRows, types), - }, - ); - const versionLabel = detail ? `v${detail.version_no}` : "—"; return (