feat(admin, settlement, dashboard): strengthen permission gating and billing workflows

This commit is contained in:
2026-06-09 13:44:19 +08:00
parent 7e65c53732
commit b7278e68a4
41 changed files with 900 additions and 199 deletions

View File

@@ -165,9 +165,6 @@ export function AgentsPlayersPanel({
const viewPlayerLabel = t("players:viewDetail", { defaultValue: "查看玩家详情" });
const editPlayerLabel = t("players:editPlayer", { defaultValue: "编辑玩家" });
const deletePlayerLabel = t("players:deletePlayer", { defaultValue: "删除玩家" });
const settlementCenterLabel = t("playersPanel.gotoSettlementCenter", {
defaultValue: "去结算中心",
});
const profile = useAdminProfile();
const boundAgent = profile?.agent ?? null;
const isSuperAdmin = profile?.is_super_admin === true;
@@ -521,10 +518,15 @@ export function AgentsPlayersPanel({
async function handlePayBill(): Promise<void> {
if (selectedBill === null) return;
const amount = parseBillingAmount(payAmount || String(selectedBill.unpaid_amount ?? 0));
if (amount === null || amount <= 0 || amount > Number(selectedBill.unpaid_amount ?? 0)) {
toast.error(t("playersPanel.paymentAmountInvalid", { defaultValue: "请输入有效的收付金额" }));
return;
}
setBillingBusy(true);
try {
await postSettlementBillPayment(selectedBill.id, {
amount: Number(payAmount || selectedBill.unpaid_amount || 0),
amount,
method: payMethod.trim() || undefined,
proof: payProof.trim() || undefined,
});
@@ -546,10 +548,15 @@ export function AgentsPlayersPanel({
async function handleWriteOffBill(): Promise<void> {
if (selectedBill === null) return;
const reason = badDebtReason.trim();
if (!reason) {
toast.error(t("playersPanel.badDebtReasonRequired", { defaultValue: "请填写核销原因" }));
return;
}
setBillingBusy(true);
try {
await postSettlementBillBadDebtWriteOff(selectedBill.id, {
reason: badDebtReason.trim() || undefined,
reason,
});
toast.success(t("playersPanel.billWrittenOff", { defaultValue: "已核销坏账" }));
await load();
@@ -567,6 +574,68 @@ export function AgentsPlayersPanel({
}
}
function parseBillingAmount(raw: string): number | null {
const value = Number(raw);
if (!Number.isFinite(value) || !Number.isInteger(value)) {
return null;
}
return value;
}
function requestConfirmBillAction(): void {
if (selectedBill === null) return;
requestConfirm({
title: t("playersPanel.confirmBillTitle", { defaultValue: "确认账单?" }),
description: t("playersPanel.confirmBillDescription", {
defaultValue: "确认后账单会进入待收付状态,请确认金额与玩家无误。",
}),
confirmLabel: t("agents:settlementBills.confirm", { defaultValue: "确认账单" }),
confirmVariant: "default",
onConfirm: handleConfirmBill,
});
}
function requestPayBillAction(): void {
if (selectedBill === null) return;
const amount = parseBillingAmount(payAmount || String(selectedBill.unpaid_amount ?? 0));
if (amount === null || amount <= 0) {
toast.error(t("playersPanel.paymentAmountInvalid", { defaultValue: "请输入大于 0 的整数金额" }));
return;
}
if (amount > Number(selectedBill.unpaid_amount ?? 0)) {
toast.error(t("playersPanel.paymentAmountTooLarge", { defaultValue: "收付金额不能超过未结金额" }));
return;
}
requestConfirm({
title: t("playersPanel.payBillConfirmTitle", { defaultValue: "确认登记收付?" }),
description: t("playersPanel.payBillConfirmDescription", {
defaultValue: "这会写入收付记录并更新玩家账单金额,请确认金额与凭证无误。",
}),
confirmLabel: t("agents:settlementBills.paid", { defaultValue: "登记收付" }),
confirmVariant: "default",
onConfirm: handlePayBill,
});
}
function requestWriteOffBillAction(): void {
if (selectedBill === null) return;
if (!badDebtReason.trim()) {
toast.error(t("playersPanel.badDebtReasonRequired", { defaultValue: "请填写核销原因" }));
return;
}
requestConfirm({
title: t("playersPanel.writeOffBillConfirmTitle", { defaultValue: "确认核销坏账?" }),
description: t("playersPanel.writeOffBillConfirmDescription", {
defaultValue: "核销会把该玩家账单未结金额归档为坏账记录,请确认已无法收回。",
}),
confirmLabel: t("agents:settlementBills.confirmBadDebt", { defaultValue: "确认核销" }),
confirmVariant: "destructive",
onConfirm: handleWriteOffBill,
});
}
return (
<div className="space-y-4">
<ConfirmDialog />
@@ -936,7 +1005,7 @@ export function AgentsPlayersPanel({
</div>
{selectedBill.status === "pending_confirm" ? (
<Button type="button" className="w-full" disabled={billingBusy} onClick={() => void handleConfirmBill()}>
<Button type="button" className="w-full" disabled={billingBusy || confirmBusy} onClick={requestConfirmBillAction}>
{t("agents:settlementBills.confirm", { defaultValue: "确认账单" })}
</Button>
) : null}
@@ -967,7 +1036,7 @@ export function AgentsPlayersPanel({
})}
/>
</div>
<Button type="button" className="w-full" disabled={billingBusy} onClick={() => void handlePayBill()}>
<Button type="button" className="w-full" disabled={billingBusy || confirmBusy} onClick={requestPayBillAction}>
{t("agents:settlementBills.paid", { defaultValue: "登记收付" })}
</Button>
@@ -981,7 +1050,7 @@ export function AgentsPlayersPanel({
})}
/>
</div>
<Button type="button" variant="destructive" className="w-full" disabled={billingBusy} onClick={() => void handleWriteOffBill()}>
<Button type="button" variant="destructive" className="w-full" disabled={billingBusy || confirmBusy} onClick={requestWriteOffBillAction}>
{t("agents:settlementBills.confirmBadDebt", { defaultValue: "确认核销" })}
</Button>
</div>

View File

@@ -48,7 +48,7 @@ import {
TableRow,
} from "@/components/ui/table";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
@@ -560,7 +560,7 @@ export function PlayConfigDocScreen() {
</div>
<div className="flex flex-col gap-1.5 md:w-[140px]">
<span className="text-sm font-medium">{t("play.filters.category", { ns: "config" })}</span>
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
<Select value={categoryFilter} onValueChange={(value) => setCategoryFilter(value ?? "all")}>
<SelectTrigger className="h-8">
<SelectValue>
{categoryFilter === "all"

View File

@@ -12,6 +12,9 @@ import { Button } from "@/components/ui/button";
import { ConfigDocPage } from "@/modules/config/config-doc-page";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { PRD_WALLET_ADJUST_MANAGE, PRD_WALLET_RECONCILE_MANAGE } from "@/lib/admin-prd";
import { useAdminProfile } from "@/stores/admin-session";
import { LotteryApiBizError } from "@/types/api/errors";
function minorUnitsToDisplay(n: unknown, decimals = 2): string {
@@ -20,9 +23,11 @@ function minorUnitsToDisplay(n: unknown, decimals = 2): string {
return (num / 100).toFixed(decimals);
}
function displayToMinorUnits(s: string): number {
const n = parseFloat(s);
if (Number.isNaN(n) || n < 0) return 0;
function displayToMinorUnits(s: string): number | null {
const normalized = s.trim();
if (normalized === "") return null;
const n = Number(normalized);
if (!Number.isFinite(n)) return null;
return Math.round(n * 100);
}
@@ -46,11 +51,44 @@ type WalletConfigDocScreenProps = {
embedded?: boolean;
};
function validateDraft(draft: Draft, t: ReturnType<typeof useTranslation<["config", "adminUsers", "common"]>>["t"]): string[] {
const errors: string[] = [];
const values = {
inMin: displayToMinorUnits(draft.inMin),
inMax: displayToMinorUnits(draft.inMax),
outMin: displayToMinorUnits(draft.outMin),
outMax: displayToMinorUnits(draft.outMax),
};
for (const field of ["inMin", "inMax", "outMin", "outMax"] as const) {
if (values[field] === null || values[field] < 1) {
errors.push(t("wallet.validation.amountAtLeastMinorUnit", {
ns: "config",
field: t(`wallet.fields.${field}`, { ns: "config" }),
}));
}
}
if (values.inMin !== null && values.inMax !== null && values.inMax < values.inMin) {
errors.push(t("wallet.validation.inRangeInvalid", { ns: "config" }));
}
if (values.outMin !== null && values.outMax !== null && values.outMax < values.outMin) {
errors.push(t("wallet.validation.outRangeInvalid", { ns: "config" }));
}
return [...new Set(errors)];
}
export function WalletConfigDocScreen({ embedded = false }: WalletConfigDocScreenProps) {
const { t } = useTranslation(["config", "adminUsers", "common"]);
const tRef = useRef(t);
tRef.current = t;
const shared = useOptionalAdminSettingsData();
const profile = useAdminProfile();
const canManage = adminHasAnyPermission(profile?.permissions, [
PRD_WALLET_RECONCILE_MANAGE,
PRD_WALLET_ADJUST_MANAGE,
]);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const [draft, setDraft] = useState<Draft>({
inMin: "",
@@ -66,6 +104,8 @@ export function WalletConfigDocScreen({ embedded = false }: WalletConfigDocScree
draft.inMax !== saved.inMax ||
draft.outMin !== saved.outMin ||
draft.outMax !== saved.outMax;
const validationErrors = validateDraft(draft, t);
const hasValidationError = validationErrors.length > 0;
const loading = embedded ? (shared?.loading ?? true) : standaloneLoading;
@@ -107,6 +147,11 @@ export function WalletConfigDocScreen({ embedded = false }: WalletConfigDocScree
};
const handleSave = async () => {
if (hasValidationError) {
toast.error(validationErrors[0]);
return;
}
const items = [];
if (draft.inMin !== saved.inMin) {
items.push({ key: WALLET_KEYS.IN_MIN, value: displayToMinorUnits(draft.inMin) });
@@ -156,7 +201,8 @@ export function WalletConfigDocScreen({ embedded = false }: WalletConfigDocScree
placeholder={t("wallet.placeholders.min", { ns: "config" })}
value={draft.inMin}
onChange={(e) => handleChange("inMin", e.target.value)}
disabled={loading || saving}
disabled={!canManage || loading || saving}
aria-invalid={hasValidationError}
/>
</div>
<div className="space-y-2">
@@ -169,7 +215,8 @@ export function WalletConfigDocScreen({ embedded = false }: WalletConfigDocScree
placeholder={t("wallet.placeholders.max", { ns: "config" })}
value={draft.inMax}
onChange={(e) => handleChange("inMax", e.target.value)}
disabled={loading || saving}
disabled={!canManage || loading || saving}
aria-invalid={hasValidationError}
/>
</div>
<div className="space-y-2">
@@ -182,7 +229,8 @@ export function WalletConfigDocScreen({ embedded = false }: WalletConfigDocScree
placeholder={t("wallet.placeholders.min", { ns: "config" })}
value={draft.outMin}
onChange={(e) => handleChange("outMin", e.target.value)}
disabled={loading || saving}
disabled={!canManage || loading || saving}
aria-invalid={hasValidationError}
/>
</div>
<div className="space-y-2">
@@ -195,10 +243,18 @@ export function WalletConfigDocScreen({ embedded = false }: WalletConfigDocScree
placeholder={t("wallet.placeholders.max", { ns: "config" })}
value={draft.outMax}
onChange={(e) => handleChange("outMax", e.target.value)}
disabled={loading || saving}
disabled={!canManage || loading || saving}
aria-invalid={hasValidationError}
/>
</div>
</div>
{validationErrors.length > 0 && (
<div className="space-y-1 text-xs text-destructive" role="alert">
{validationErrors.map((error) => (
<p key={error}>{error}</p>
))}
</div>
)}
<div className="flex items-center gap-4 pt-2">
<Button
onClick={() =>
@@ -209,7 +265,7 @@ export function WalletConfigDocScreen({ embedded = false }: WalletConfigDocScree
onConfirm: () => handleSave(),
})
}
disabled={!dirty || loading || saving}
disabled={!canManage || !dirty || hasValidationError || loading || saving}
>
{saving ? t("saving", { ns: "adminUsers" }) : t("actions.save", { ns: "adminUsers" })}
</Button>

View File

@@ -5,9 +5,13 @@ import { useCallback, useMemo, useState, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import {
BarChart3,
Flame,
Landmark,
Network,
RefreshCw,
Sparkles,
Ticket,
TrendingUp,
Users,
Wallet,
} from "lucide-react";
@@ -48,7 +52,7 @@ export function AgentDashboardConsole(): ReactElement {
const tRef = useTranslationRef(["dashboard", "common"]);
const profile = useAdminProfile();
const agent = profile?.agent ?? null;
const permissions = profile?.permissions ?? [];
const permissions = useMemo(() => profile?.permissions ?? [], [profile?.permissions]);
const todayLabel = useMemo(() => {
const locale = normalizeAdminLanguage(i18n.resolvedLanguage ?? i18n.language);
@@ -109,6 +113,7 @@ export function AgentDashboardConsole(): ReactElement {
}, []);
const currency = "NPR";
const displayCurrency = overview?.currency_code ?? currency;
const quickLinks = useMemo(() => {
const links: { href: string; label: string; icon: ReactElement }[] = [];
@@ -189,78 +194,268 @@ export function AgentDashboardConsole(): ReactElement {
))}
</div>
) : overview ? (
<section className="grid min-w-0 grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">{t("agent.creditTitle")}</CardTitle>
</CardHeader>
<CardContent className="space-y-1 text-sm">
<p className="text-2xl font-semibold tabular-nums">
{formatDashboardCreditMajor(overview.credit_limit, currency)}
</p>
<p className="text-xs text-muted-foreground">
{t("agent.creditAvailable", {
amount: formatDashboardCreditMajor(overview.available_credit, currency),
})}
</p>
<p className="text-xs text-muted-foreground">
{t("agent.creditAllocated", {
amount: formatDashboardCreditMajor(overview.allocated_credit, currency),
})}
{" · "}
{t("agent.creditUsed", {
amount: formatDashboardCreditMajor(overview.used_credit, currency),
})}
</p>
<p className="text-xs text-muted-foreground">
{t("agent.shareRate", { rate: overview.total_share_rate })}
{" · "}
{t("agent.settlementCycle", { cycle: overview.settlement_cycle })}
</p>
</CardContent>
</Card>
<section className="space-y-4">
<div className="grid min-w-0 grid-cols-1 gap-4 xl:grid-cols-[1.35fr_0.95fr]">
<Card className="overflow-hidden border-slate-200 bg-[radial-gradient(circle_at_top_left,_rgba(16,185,129,0.18),_transparent_42%),linear-gradient(135deg,_#0f172a,_#111827_55%,_#1f2937)] text-white shadow-[0_20px_60px_-25px_rgba(15,23,42,0.65)]">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-[0.24em] text-emerald-200/80">
{t("agent.heroEyebrow")}
</p>
<CardTitle className="mt-2 text-2xl font-semibold">
{t("agent.heroTitle", { name: overview.agent_name || overview.agent_code })}
</CardTitle>
</div>
<div className="rounded-full border border-white/15 bg-white/10 p-2">
<Sparkles className="size-4 text-emerald-200" />
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-2xl border border-white/10 bg-white/8 p-4 backdrop-blur">
<p className="text-xs text-slate-300">{t("agent.todayBet")}</p>
<p className="mt-2 text-2xl font-semibold tabular-nums">
{formatDashboardMoneyMinor(overview.today_bet_minor, displayCurrency)}
</p>
</div>
<div className="rounded-2xl border border-white/10 bg-white/8 p-4 backdrop-blur">
<p className="text-xs text-slate-300">{t("agent.todayPayout")}</p>
<p className="mt-2 text-2xl font-semibold tabular-nums">
{formatDashboardMoneyMinor(overview.today_payout_minor, displayCurrency)}
</p>
</div>
<div className="rounded-2xl border border-white/10 bg-white/8 p-4 backdrop-blur">
<p className="text-xs text-slate-300">{t("agent.todayProfit")}</p>
<p className="mt-2 text-2xl font-semibold tabular-nums">
{formatDashboardMoneyMinor(overview.today_profit_minor, displayCurrency)}
</p>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-xl border border-white/10 px-4 py-3">
<p className="text-[11px] text-slate-300">{t("agent.activePlayersToday")}</p>
<p className="mt-1 text-lg font-semibold tabular-nums">{overview.active_player_count_today}</p>
</div>
<div className="rounded-xl border border-white/10 px-4 py-3">
<p className="text-[11px] text-slate-300">{t("agent.betOrdersToday")}</p>
<p className="mt-1 text-lg font-semibold tabular-nums">{overview.bet_order_count_today}</p>
</div>
<div className="rounded-xl border border-white/10 px-4 py-3">
<p className="text-[11px] text-slate-300">{t("agent.pendingBills")}</p>
<p className="mt-1 text-lg font-semibold tabular-nums">{overview.pending_bill_count}</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-slate-300">
<span>{t("agent.shareRate", { rate: overview.total_share_rate })}</span>
<span>{t("agent.settlementCycle", { cycle: overview.settlement_cycle })}</span>
<span>
{overview.latest_bet_at
? t("agent.latestBetAt", { time: new Date(overview.latest_bet_at).toLocaleString() })
: t("agent.noBetToday")}
</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">{t("agent.teamTitle")}</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-3 text-sm">
<div>
<p className="text-xs text-muted-foreground">{t("agent.directChildren")}</p>
<p className="text-xl font-semibold tabular-nums">{overview.direct_child_count}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">{t("agent.directPlayers")}</p>
<p className="text-xl font-semibold tabular-nums">{overview.direct_player_count}</p>
</div>
<div className="col-span-2">
<p className="text-xs text-muted-foreground">{t("agent.subtreeAgents")}</p>
<p className="text-lg font-semibold tabular-nums">{overview.subtree_agent_count}</p>
</div>
</CardContent>
</Card>
<Card className="border-slate-200 bg-[linear-gradient(180deg,_rgba(248,250,252,0.98),_rgba(241,245,249,0.92))]">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold">{t("agent.creditTitle")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<p className="text-3xl font-semibold tabular-nums text-slate-900">
{formatDashboardCreditMajor(overview.credit_limit, displayCurrency)}
</p>
<p className="mt-1 text-xs text-slate-500">
{t("agent.creditAvailable", {
amount: formatDashboardCreditMajor(overview.available_credit, displayCurrency),
})}
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-2xl border bg-white px-4 py-3">
<p className="text-xs text-slate-500">{t("agent.creditAllocatedLabel")}</p>
<p className="mt-1 text-lg font-semibold tabular-nums">
{formatDashboardCreditMajor(overview.allocated_credit, displayCurrency)}
</p>
</div>
<div className="rounded-2xl border bg-white px-4 py-3">
<p className="text-xs text-slate-500">{t("agent.creditUsedLabel")}</p>
<p className="mt-1 text-lg font-semibold tabular-nums">
{formatDashboardCreditMajor(overview.used_credit, displayCurrency)}
</p>
</div>
</div>
<p className="text-xs text-slate-500">
{t("agent.pendingUnpaid", {
amount: formatDashboardMoneyMinor(overview.pending_unpaid_minor, displayCurrency),
})}
</p>
</CardContent>
</Card>
</div>
<Card className="sm:col-span-2 xl:col-span-2">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-semibold">{t("agent.pendingBills")}</CardTitle>
{adminHasAnyPermission(permissions, [...PRD_SETTLEMENT_AGENT_ACCESS_ANY]) ? (
<Link
href="/admin/settlement-center"
className={cn(buttonVariants({ variant: "link", size: "sm" }), "h-auto px-0 text-xs")}
>
{t("agent.viewBills")}
</Link>
) : null}
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold tabular-nums">{overview.pending_bill_count}</p>
<p className="mt-1 text-xs text-muted-foreground">
{t("agent.pendingUnpaid", {
amount: formatDashboardMoneyMinor(overview.pending_unpaid_minor, currency),
})}
</p>
</CardContent>
</Card>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<TrendingUp className="size-4 text-emerald-600" />
{t("agent.sevenDayTitle")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-1 text-sm">
<p className="text-xl font-semibold tabular-nums">
{formatDashboardMoneyMinor(overview.seven_day_bet_minor, displayCurrency)}
</p>
<p className="text-xs text-muted-foreground">
{t("agent.sevenDayPayout", {
amount: formatDashboardMoneyMinor(overview.seven_day_payout_minor, displayCurrency),
})}
</p>
<p className="text-xs text-muted-foreground">
{t("agent.sevenDayProfit", {
amount: formatDashboardMoneyMinor(overview.seven_day_profit_minor, displayCurrency),
})}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<Users className="size-4 text-sky-600" />
{t("agent.teamTitle")}
</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-3 text-sm">
<div>
<p className="text-xs text-muted-foreground">{t("agent.directChildren")}</p>
<p className="text-xl font-semibold tabular-nums">{overview.direct_child_count}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">{t("agent.subtreeAgents")}</p>
<p className="text-xl font-semibold tabular-nums">{overview.subtree_agent_count}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">{t("agent.directPlayers")}</p>
<p className="text-xl font-semibold tabular-nums">{overview.direct_player_count}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">{t("agent.teamPlayers")}</p>
<p className="text-xl font-semibold tabular-nums">{overview.team_player_count}</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<Wallet className="size-4 text-amber-600" />
{t("agent.pendingBills")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-1">
<p className="text-2xl font-semibold tabular-nums">{overview.pending_bill_count}</p>
<p className="text-xs text-muted-foreground">
{t("agent.pendingUnpaid", {
amount: formatDashboardMoneyMinor(overview.pending_unpaid_minor, displayCurrency),
})}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<Flame className="size-4 text-rose-600" />
{t("agent.topMomentum")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-1">
{overview.top_agent_today ? (
<>
<p className="text-sm font-semibold text-slate-900">
{overview.top_agent_today.agent_name || overview.top_agent_today.agent_code}
</p>
<p className="text-xl font-semibold tabular-nums">
{formatDashboardMoneyMinor(overview.top_agent_today.total_bet_minor, displayCurrency)}
</p>
<p className="text-xs text-muted-foreground">
{t("agent.topMomentumHint", {
profit: formatDashboardMoneyMinor(
overview.top_agent_today.approx_house_gross_minor,
displayCurrency,
),
})}
</p>
</>
) : (
<p className="text-sm text-muted-foreground">{t("agent.noBetToday")}</p>
)}
</CardContent>
</Card>
</div>
<div className="grid gap-4 xl:grid-cols-[1.15fr_0.85fr]">
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<Landmark className="size-4 text-slate-700" />
{t("agent.managementFocus")}
</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-3">
<div className="rounded-2xl border bg-slate-50 px-4 py-3">
<p className="text-xs text-slate-500">{t("agent.focusBet")}</p>
<p className="mt-1 text-lg font-semibold tabular-nums">
{formatDashboardMoneyMinor(overview.today_bet_minor, displayCurrency)}
</p>
</div>
<div className="rounded-2xl border bg-slate-50 px-4 py-3">
<p className="text-xs text-slate-500">{t("agent.focusPlayers")}</p>
<p className="mt-1 text-lg font-semibold tabular-nums">{overview.active_player_count_today}</p>
</div>
<div className="rounded-2xl border bg-slate-50 px-4 py-3">
<p className="text-xs text-slate-500">{t("agent.focusBills")}</p>
<p className="mt-1 text-lg font-semibold tabular-nums">{overview.pending_bill_count}</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">{t("agent.quickStatsTitle")}</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">{t("agent.canCreateChildAgent")}</span>
<span className="font-medium">
{overview.can_create_child_agent ? t("agent.yes") : t("agent.no")}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">{t("agent.canCreatePlayer")}</span>
<span className="font-medium">
{overview.can_create_player ? t("agent.yes") : t("agent.no")}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">{t("agent.lineDepth")}</span>
<span className="font-medium tabular-nums">{overview.depth}</span>
</div>
{adminHasAnyPermission(permissions, [...PRD_SETTLEMENT_AGENT_ACCESS_ANY]) ? (
<Link
href="/admin/settlement-center"
className={cn(buttonVariants({ variant: "link", size: "sm" }), "h-auto px-0")}
>
{t("agent.viewBills")}
</Link>
) : null}
</CardContent>
</Card>
</div>
</section>
) : null}

View File

@@ -35,7 +35,6 @@ import { drawStatusLabel, hallPreviewDiffersFromDbStatus } from "./draw-display"
import { DrawStatusBadge } from "./draw-status-badge";
import {
PRD_DRAW_REOPEN_MANAGE,
PRD_DRAW_RESULT_MANAGE,
PRD_PAYOUT_MANAGE,
PRD_PAYOUT_REVIEW,
} from "./draw-prd";
@@ -173,7 +172,7 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
label: t("manualClose"),
variant: "outline",
enabled: ["pending", "open"].includes(data.status),
onConfirm: () => postAdminManualCloseDraw(idNum),
onConfirm: async () => { await postAdminManualCloseDraw(idNum); },
confirmTitle: t("confirm.manualCloseTitle"),
confirmDescription: t("confirm.manualCloseDescription"),
},
@@ -182,7 +181,7 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
label: t("cancelBeforeDraw"),
variant: "outline",
enabled: ["pending", "open", "closing", "closed"].includes(data.status),
onConfirm: () => postAdminCancelDraw(idNum),
onConfirm: async () => { await postAdminCancelDraw(idNum); },
confirmTitle: t("confirm.cancelDrawTitle"),
confirmDescription: t("confirm.cancelDrawDescription"),
},
@@ -191,7 +190,7 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
label: t("rngAutoGenerate"),
variant: "outline",
enabled: data.status === "closed",
onConfirm: () => postAdminRunDrawRng(idNum),
onConfirm: async () => { await postAdminRunDrawRng(idNum); },
confirmTitle: t("confirm.rngDrawTitle"),
confirmDescription: t("confirm.rngDrawDescription"),
},
@@ -204,7 +203,7 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
label: t("cooldownReopen"),
variant: "destructive",
enabled: data.status === "cooldown",
onConfirm: () => postAdminReopenDraw(idNum),
onConfirm: async () => { await postAdminReopenDraw(idNum); },
confirmTitle: t("confirm.reopenTitle"),
confirmDescription: t("confirm.reopenDescription"),
confirmVariant: "destructive",
@@ -217,7 +216,7 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
label: t("runSettlement"),
variant: "outline",
enabled: data.status === "settling",
onConfirm: () => postAdminRunDrawSettlement(idNum),
onConfirm: async () => { await postAdminRunDrawSettlement(idNum); },
confirmTitle: t("confirm.runSettlementTitle"),
confirmDescription: t("confirm.runSettlementDescription"),
});

View File

@@ -18,7 +18,7 @@ import {
putAdminIntegrationSite,
} from "@/api/admin-integration-sites";
import { AdminPageCard } from "@/components/admin/admin-page-card";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { Button } from "@/components/ui/button";
@@ -41,7 +41,7 @@ import {
TableRow,
} from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { getAdminPageBundle } from "@/lib/admin-permission-bundles";
import { useAdminProfile } from "@/stores/admin-session";
@@ -252,7 +252,7 @@ export function IntegrationSitesConsole({
} finally {
setLoading(false);
}
}, []);
}, [tRef]);
useAsyncEffect(() => {
void load();
@@ -572,6 +572,7 @@ export function IntegrationSitesConsole({
key: "connectivity",
label: t("integrationSites.connectivityTest"),
icon: Link2,
hidden: !canManage,
onClick: () => openConnectivity(row),
},
{
@@ -790,7 +791,7 @@ export function IntegrationSitesConsole({
</Button>
<Button
type="button"
disabled={connectivityBusy || connectivityPlayerId.trim() === ""}
disabled={!canManage || connectivityBusy || connectivityPlayerId.trim() === ""}
onClick={() => void runConnectivityTest()}
>
{connectivityBusy

View File

@@ -23,7 +23,7 @@ import {
import { flattenAgentTree, type FlatAgentOption } from "@/lib/admin-agent-tree";
import { useAdminSiteCodeOptions } from "@/hooks/use-admin-site-code-options";
import { AdminAgentCell, AdminAgentHead } from "@/components/admin/admin-agent-columns";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
@@ -41,7 +41,7 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import { AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { PRD_PLAYER_FREEZE_MANAGE, PRD_USERS_MANAGE } from "@/lib/admin-prd";
import { useAdminProfile } from "@/stores/admin-session";
@@ -705,7 +705,7 @@ export function PlayersConsole(): React.ReactElement {
<Select
value={formSiteCode}
onValueChange={(code) => {
setFormSiteCode(code);
setFormSiteCode(code ?? "");
setFormAgentNodeId(undefined);
}}
>

View File

@@ -12,6 +12,9 @@ import type { AdminSettingBatchItem } from "@/api/admin-settings";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { PRD_DRAW_RESULT_MANAGE } from "@/lib/admin-prd";
import { useAdminProfile } from "@/stores/admin-session";
interface DrawDraft {
defaultCurrency: string;
@@ -90,6 +93,8 @@ function buildDirtyItems(draft: DrawDraft, saved: DrawDraft): AdminSettingBatchI
export function DrawSettingsPanel() {
const { t } = useTranslation(["config", "adminUsers", "common"]);
const profile = useAdminProfile();
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_DRAW_RESULT_MANAGE]);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const buildItems = useCallback(buildDirtyItems, []);
const section = useSettingsSection({
@@ -113,7 +118,7 @@ export function DrawSettingsPanel() {
<Label className="text-sm font-medium">{t("system.fields.manualReview", { ns: "config" })}</Label>
<Switch
checked={draft.requireManualReview}
disabled={loading || saving}
disabled={!canManage || loading || saving}
aria-label={t("system.fields.manualReview", { ns: "config" })}
onCheckedChange={(value) => updateField("requireManualReview", value)}
/>
@@ -131,7 +136,7 @@ export function DrawSettingsPanel() {
value={draft.defaultCurrency}
placeholder={t("system.placeholders.defaultCurrency", { ns: "config" })}
onChange={(e) => updateField("defaultCurrency", e.target.value.toUpperCase())}
disabled={loading || saving}
disabled={!canManage || loading || saving}
maxLength={16}
/>
</div>
@@ -148,7 +153,7 @@ export function DrawSettingsPanel() {
value={draft.drawIntervalMinutes}
placeholder={t("system.placeholders.drawIntervalMinutes", { ns: "config" })}
onChange={(e) => updateField("drawIntervalMinutes", e.target.value)}
disabled={loading || saving}
disabled={!canManage || loading || saving}
/>
</div>
<div className="grid gap-2">
@@ -163,7 +168,7 @@ export function DrawSettingsPanel() {
value={draft.drawBettingWindowSeconds}
placeholder={t("system.placeholders.drawBettingWindowSeconds", { ns: "config" })}
onChange={(e) => updateField("drawBettingWindowSeconds", e.target.value)}
disabled={loading || saving}
disabled={!canManage || loading || saving}
/>
</div>
<div className="grid gap-2">
@@ -178,7 +183,7 @@ export function DrawSettingsPanel() {
value={draft.drawCloseBeforeDrawSeconds}
placeholder={t("system.placeholders.drawCloseBeforeDrawSeconds", { ns: "config" })}
onChange={(e) => updateField("drawCloseBeforeDrawSeconds", e.target.value)}
disabled={loading || saving}
disabled={!canManage || loading || saving}
/>
</div>
<div className="grid gap-2">
@@ -193,7 +198,7 @@ export function DrawSettingsPanel() {
value={draft.drawBufferDrawsAhead}
placeholder={t("system.placeholders.drawBufferDrawsAhead", { ns: "config" })}
onChange={(e) => updateField("drawBufferDrawsAhead", e.target.value)}
disabled={loading || saving}
disabled={!canManage || loading || saving}
/>
</div>
<div className="grid gap-2">
@@ -208,14 +213,14 @@ export function DrawSettingsPanel() {
value={draft.cooldownMinutes}
placeholder={t("system.placeholders.cooldownMinutes", { ns: "config" })}
onChange={(e) => updateField("cooldownMinutes", e.target.value)}
disabled={loading || saving}
disabled={!canManage || loading || saving}
/>
</div>
</div>
<SettingsSectionActions
dirty={dirty}
loading={loading}
loading={!canManage || loading}
saving={saving}
onSave={() =>
requestConfirm({

View File

@@ -12,6 +12,9 @@ import type { AdminSettingBatchItem } from "@/api/admin-settings";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { PRD_ODDS_MANAGE, PRD_REBATE_MANAGE } from "@/lib/admin-prd";
import { useAdminProfile } from "@/stores/admin-session";
interface FrontendDraft {
playRulesHtmlZh: string;
@@ -51,6 +54,8 @@ function buildDirtyItems(draft: FrontendDraft, saved: FrontendDraft): AdminSetti
export function FrontendSettingsPanel() {
const { t } = useTranslation(["config", "adminUsers", "common"]);
const profile = useAdminProfile();
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_ODDS_MANAGE, PRD_REBATE_MANAGE]);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const buildItems = useCallback(buildDirtyItems, []);
const section = useSettingsSection({
@@ -84,7 +89,7 @@ export function FrontendSettingsPanel() {
id="play-rules-html-zh"
value={draft.playRulesHtmlZh}
onChange={(e) => updateField("playRulesHtmlZh", e.target.value)}
disabled={loading || saving}
disabled={!canManage || loading || saving}
className="min-h-[200px] font-mono text-xs"
placeholder="<div>...</div>"
/>
@@ -94,7 +99,7 @@ export function FrontendSettingsPanel() {
id="play-rules-html-en"
value={draft.playRulesHtmlEn}
onChange={(e) => updateField("playRulesHtmlEn", e.target.value)}
disabled={loading || saving}
disabled={!canManage || loading || saving}
className="min-h-[200px] font-mono text-xs"
placeholder="<div>...</div>"
/>
@@ -104,7 +109,7 @@ export function FrontendSettingsPanel() {
id="play-rules-html-ne"
value={draft.playRulesHtmlNe}
onChange={(e) => updateField("playRulesHtmlNe", e.target.value)}
disabled={loading || saving}
disabled={!canManage || loading || saving}
className="min-h-[200px] font-mono text-xs"
placeholder="<div>...</div>"
/>
@@ -113,7 +118,7 @@ export function FrontendSettingsPanel() {
<SettingsSectionActions
dirty={dirty}
loading={loading}
loading={!canManage || loading}
saving={saving}
onSave={() =>
requestConfirm({

View File

@@ -12,6 +12,7 @@ import type { AdminSettingBatchItem } from "@/api/admin-settings";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { PRD_PAYOUT_MANAGE } from "@/lib/admin-prd";
import { useAdminProfile } from "@/stores/admin-session";
interface SettlementDraft {
@@ -57,7 +58,7 @@ function buildDirtyItems(draft: SettlementDraft, saved: SettlementDraft): AdminS
export function SettlementSettingsPanel() {
const { t } = useTranslation(["config", "adminUsers", "common"]);
const profile = useAdminProfile();
const canManage = adminHasAnyPermission(profile?.permissions, ["prd.payout.manage"]);
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_PAYOUT_MANAGE]);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const buildItems = useCallback(buildDirtyItems, []);
const section = useSettingsSection({

View File

@@ -13,7 +13,7 @@ import {
postSettlementBillPayment,
type RebateAllocationRow,
type SettlementBillRow,
type SettlementPaymentRow,
type SettlementBillPaymentRow,
} from "@/api/admin-agent-settlement";
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
@@ -26,6 +26,8 @@ import { describeBillPaymentDirection } from "@/modules/settlement/settlement-bi
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { LotteryApiBizError } from "@/types/api/errors";
type AgentBillDetailProps = {
billId: number;
@@ -42,15 +44,17 @@ export function AgentBillDetail({
}: AgentBillDetailProps): React.ReactElement {
const { t } = useTranslation(["agents", "settlementCenter", "common"]);
const [bill, setBill] = useState<SettlementBillRow | null>(null);
const [payments, setPayments] = useState<SettlementPaymentRow[]>([]);
const [payments, setPayments] = useState<SettlementBillPaymentRow[]>([]);
const [rebateAllocations, setRebateAllocations] = useState<RebateAllocationRow[]>([]);
const [loading, setLoading] = useState(true);
const [payAmount, setPayAmount] = useState("");
const [payMethod, setPayMethod] = useState("");
const [payProof, setPayProof] = useState("");
const [adjustAmount, setAdjustAmount] = useState("");
const [adjustReason, setAdjustReason] = useState("");
const [badDebtReason, setBadDebtReason] = useState("");
const [rebateDetailsOpen, setRebateDetailsOpen] = useState(false);
const { request: requestConfirm, ConfirmDialog, busy: confirmBusy } = useConfirmAction();
const load = useCallback(async () => {
setLoading(true);
@@ -111,7 +115,153 @@ export function AgentBillDetail({
),
).sort((a, b) => b.amount - a.amount || a.label.localeCompare(b.label, "zh-CN"));
const showActionError = (err: unknown, fallback: string): void => {
toast.error(err instanceof LotteryApiBizError ? err.message : fallback);
};
const parseWholeAmount = (raw: string): number | null => {
const value = Number(raw);
if (!Number.isFinite(value) || !Number.isInteger(value)) {
return null;
}
return value;
};
const requestConfirmBill = (): void => {
requestConfirm({
title: t("settlementBills.confirmBillTitle", { defaultValue: "确认账单?" }),
description: t("settlementBills.confirmBillDescription", {
defaultValue: "确认后账单会进入待收付状态,请确认金额与双方无误。",
}),
confirmLabel: t("settlementBills.confirm", { defaultValue: "确认账单" }),
confirmVariant: "default",
onConfirm: async () => {
try {
await postSettlementBillConfirm(billId);
await load();
await onUpdated?.();
toast.success(t("settlementBills.confirmed", { defaultValue: "已确认" }));
} catch (err: unknown) {
showActionError(
err,
t("settlementBills.confirmFailed", { defaultValue: "确认账单失败" }),
);
}
},
});
};
const requestPayment = (): void => {
const amount = parseWholeAmount(payAmount);
if (amount === null || amount <= 0) {
toast.error(t("settlementBills.paymentAmountInvalid", { defaultValue: "请输入大于 0 的整数金额" }));
return;
}
if (amount > bill.unpaid_amount) {
toast.error(t("settlementBills.paymentAmountTooLarge", { defaultValue: "收付金额不能超过未结金额" }));
return;
}
requestConfirm({
title: t("settlementBills.paymentConfirmTitle", { defaultValue: "确认登记收付?" }),
description: t("settlementBills.paymentConfirmDescription", {
defaultValue: "这会写入收付记录并更新账单已付/未结金额,请确认金额与方向无误。",
}),
confirmLabel: paymentSubmit,
confirmVariant: "default",
onConfirm: async () => {
try {
await postSettlementBillPayment(billId, {
amount,
method: payMethod.trim() || undefined,
proof: payProof.trim() || undefined,
});
await load();
await onUpdated?.();
toast.success(t("settlementBills.paid", { defaultValue: "已登记收付" }));
} catch (err: unknown) {
showActionError(
err,
t("settlementBills.paymentFailed", { defaultValue: "登记收付失败" }),
);
}
},
});
};
const requestBadDebtWriteOff = (): void => {
const reason = badDebtReason.trim();
if (!reason) {
toast.error(t("settlementBills.badDebtReasonRequired", { defaultValue: "请填写核销原因" }));
return;
}
requestConfirm({
title: t("settlementBills.badDebtConfirmTitle", { defaultValue: "确认核销坏账?" }),
description: t("settlementBills.badDebtConfirmDescription", {
defaultValue: "核销会把未结金额归档为坏账记录,这类资金动作需要确认原因无误。",
}),
confirmLabel: t("settlementBills.confirmBadDebt", { defaultValue: "确认核销" }),
confirmVariant: "destructive",
onConfirm: async () => {
try {
await postSettlementBillBadDebtWriteOff(billId, { reason });
await load();
await onUpdated?.();
toast.success(t("settlementBills.badDebtDone", { defaultValue: "已核销坏账" }));
} catch (err: unknown) {
showActionError(
err,
t("settlementBills.badDebtFailed", { defaultValue: "核销坏账失败" }),
);
}
},
});
};
const requestAdjustment = (): void => {
const amount = parseWholeAmount(adjustAmount);
const reason = adjustReason.trim();
if (amount === null || amount === 0) {
toast.error(t("settlementBills.adjustmentAmountInvalid", { defaultValue: "请输入非 0 的整数调整金额" }));
return;
}
if (!reason) {
toast.error(t("settlementBills.adjustmentReasonRequired", { defaultValue: "请填写补差/冲正原因" }));
return;
}
requestConfirm({
title: t("settlementBills.adjustmentConfirmTitle", { defaultValue: "确认创建补差/冲正单?" }),
description: t("settlementBills.adjustmentConfirmDescription", {
defaultValue: "提交后会生成独立调账单,需要后续确认与收付;请确认正负方向和原因无误。",
}),
confirmLabel: t("settlementBills.createAdjustment", { defaultValue: "创建补差单" }),
confirmVariant: amount < 0 ? "destructive" : "default",
onConfirm: async () => {
try {
await postSettlementBillAdjustment(billId, {
amount,
adjustment_type: amount < 0 ? "reversal" : "adjustment",
reason,
});
setAdjustAmount("");
setAdjustReason("");
toast.success(t("settlementBills.adjustmentCreated", { defaultValue: "已创建补差单" }));
await onUpdated?.();
} catch (err: unknown) {
showActionError(
err,
t("settlementBills.adjustmentFailed", { defaultValue: "创建补差单失败" }),
);
}
},
});
};
return (
<>
<ConfirmDialog />
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.35fr)_minmax(340px,0.95fr)]">
<div className="space-y-5 text-sm">
<SettlementBillSummaryHeader bill={bill} currencyCode={currencyCode} />
@@ -218,12 +368,8 @@ export function AgentBillDetail({
<Button
type="button"
className="w-full"
onClick={() =>
void postSettlementBillConfirm(billId)
.then(load)
.then(onUpdated)
.then(() => toast.success(t("settlementBills.confirmed", { defaultValue: "已确认" })))
}
disabled={confirmBusy}
onClick={requestConfirmBill}
>
{t("settlementBills.confirm", { defaultValue: "确认账单" })}
</Button>
@@ -279,16 +425,8 @@ export function AgentBillDetail({
<Button
type="button"
className="w-full"
onClick={() =>
void postSettlementBillPayment(billId, {
amount: Number(payAmount),
method: payMethod.trim() || undefined,
proof: payProof.trim() || undefined,
})
.then(load)
.then(onUpdated)
.then(() => toast.success(t("settlementBills.paid", { defaultValue: "已登记收付" })))
}
disabled={confirmBusy}
onClick={requestPayment}
>
{paymentSubmit}
</Button>
@@ -321,16 +459,8 @@ export function AgentBillDetail({
type="button"
variant="destructive"
className="w-full"
onClick={() =>
void postSettlementBillBadDebtWriteOff(billId, {
reason: badDebtReason.trim() || undefined,
})
.then(load)
.then(onUpdated)
.then(() =>
toast.success(t("settlementBills.badDebtDone", { defaultValue: "已核销坏账" })),
)
}
disabled={confirmBusy}
onClick={requestBadDebtWriteOff}
>
{t("settlementBills.confirmBadDebt", { defaultValue: "确认核销" })}
</Button>
@@ -360,20 +490,22 @@ export function AgentBillDetail({
})}
/>
</div>
<div className="space-y-1">
<Label>{t("settlementBills.adjustmentReason", { defaultValue: "调整原因" })}</Label>
<Input
value={adjustReason}
onChange={(e) => setAdjustReason(e.target.value)}
placeholder={t("settlementBills.adjustmentReasonPlaceholder", {
defaultValue: "例如:人工复核补差、冲正错账",
})}
/>
</div>
<Button
type="button"
variant="outline"
className="w-full"
onClick={() =>
void postSettlementBillAdjustment(billId, {
amount: Number(adjustAmount),
reason: "manual_adjustment",
})
.then(() =>
toast.success(t("settlementBills.adjustmentCreated", { defaultValue: "已创建补差单" })),
)
.then(onUpdated)
}
disabled={confirmBusy}
onClick={requestAdjustment}
>
{t("settlementBills.createAdjustment", { defaultValue: "创建补差单" })}
</Button>
@@ -381,5 +513,6 @@ export function AgentBillDetail({
) : null}
</div>
</div>
</>
);
}

View File

@@ -143,6 +143,8 @@ export function SettlementCreditLedgerPanel({
const refLabel = (row: SettlementCreditLedgerRow): string => {
const parts: string[] = [];
const ticketLabel = row.ticket_item_id ? `#${row.ticket_item_id}` : null;
const billLabel = row.settlement_bill_id ? `#${row.settlement_bill_id}` : null;
if (row.biz_no) {
parts.push(row.biz_no);
}
@@ -152,14 +154,57 @@ export function SettlementCreditLedgerPanel({
if (row.play_code) {
parts.push(row.play_code);
}
if (row.ticket_item_id) {
parts.push(`#${row.ticket_item_id}`);
if (ticketLabel) {
parts.push(ticketLabel);
}
if (row.settlement_bill_id) {
parts.push(`#${row.settlement_bill_id}`);
if (billLabel) {
parts.push(billLabel);
}
return parts.length > 0 ? parts.join(" · ") : "—";
const normalizedParts = parts
.map((part) => {
let normalized = part;
if (billLabel) {
normalized = normalized.replace(new RegExp(`\\bbill${billLabel.replace("#", "\\#")}\\b`, "g"), "");
}
if (ticketLabel) {
normalized = normalized.replace(new RegExp(`\\bticket_item${ticketLabel.replace("#", "\\#")}\\b`, "g"), "");
}
return normalized
.split(" · ")
.map((segment) => segment.trim())
.filter(Boolean)
.join(" · ");
})
.filter(Boolean);
const uniqueParts = normalizedParts.filter((part, index) => {
if (normalizedParts.indexOf(part) !== index) {
return false;
}
if (billLabel && part !== billLabel && part.endsWith(billLabel)) {
return false;
}
if (ticketLabel && part !== ticketLabel && part.endsWith(ticketLabel)) {
return false;
}
return true;
});
return uniqueParts.length > 0 ? uniqueParts.join(" · ") : "—";
};
const statusLabel = (row: SettlementCreditLedgerRow): string => {
if (row.bill_status) {
return settlementBillStatusLabel(row.bill_status, t);
}
if (row.status === "posted") {
return t("creditLedger.status.posted", { defaultValue: "已入账" });
}
return row.status || "—";
};
return (
@@ -306,7 +351,7 @@ export function SettlementCreditLedgerPanel({
{row.settlement_bill_id ? `#${row.settlement_bill_id}` : "—"}
</TableCell>
<TableCell className="text-xs">
{row.bill_status ? settlementBillStatusLabel(row.bill_status, t) : row.status}
{statusLabel(row)}
</TableCell>
<TableCell className="tabular-nums text-right text-xs">
<span className={cn(signedLedgerAmountClass(signed))}>

View File

@@ -406,7 +406,7 @@ export function PlayerTicketsConsole(): React.ReactElement {
? [
{
key: "view-player",
label: t("viewDetail", { ns: "players" }),
label: t("viewPlayer", { ns: "tickets" }),
icon: Eye,
href: adminPlayerDetailPath(row.player_id),
},