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

@@ -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))}>