feat(admin, settlement, dashboard): strengthen permission gating and billing workflows
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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))}>
|
||||
|
||||
Reference in New Issue
Block a user