实现企业银行账户与建行制单(corporate-bank-account-payment-order)
这个提交包含在:
@@ -0,0 +1,169 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
createPaymentAccount,
|
||||
disablePaymentAccount,
|
||||
fetchBankInstitutions,
|
||||
fetchPaymentAccounts,
|
||||
replacePaymentAccount,
|
||||
revealPaymentAccount,
|
||||
type PaymentAccount,
|
||||
} from "../api/paymentAccounts";
|
||||
import { fetchLegalEntities } from "../api/masterData";
|
||||
import { isApiError } from "../api/types";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasPermission } from "../auth/permissions";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||
import { SectionPanel } from "../components/patterns/SectionPanel";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../components/ui/dialog";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Label } from "../components/ui/label";
|
||||
import { Textarea } from "../components/ui/textarea";
|
||||
|
||||
function errorText(error: unknown) {
|
||||
return isApiError(error) ? `${error.code}: ${error.message}` : error instanceof Error ? error.message : "操作失败";
|
||||
}
|
||||
|
||||
export function PaymentAccountsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { permissions } = useAuth();
|
||||
const canManage = hasPermission(permissions, "payment.account.manage");
|
||||
const canReveal = hasPermission(permissions, "payment.account.read_full");
|
||||
const [editing, setEditing] = useState<PaymentAccount | null>(null);
|
||||
const [legalEntityId, setLegalEntityId] = useState("");
|
||||
const [bankInstitutionId, setBankInstitutionId] = useState("");
|
||||
const [accountName, setAccountName] = useState("");
|
||||
const [accountNumber, setAccountNumber] = useState("");
|
||||
const [revealTarget, setRevealTarget] = useState<PaymentAccount | null>(null);
|
||||
const [revealReason, setRevealReason] = useState("");
|
||||
const [revealedAccount, setRevealedAccount] = useState("");
|
||||
const [disableTarget, setDisableTarget] = useState<PaymentAccount | null>(null);
|
||||
|
||||
const accountsQuery = useQuery({ queryKey: ["payment-accounts"], queryFn: fetchPaymentAccounts });
|
||||
const banksQuery = useQuery({ queryKey: ["bank-institutions"], queryFn: fetchBankInstitutions });
|
||||
const legalEntitiesQuery = useQuery({
|
||||
queryKey: ["master-data", "legal-entities", "payment-accounts"],
|
||||
queryFn: () => fetchLegalEntities({ enabled: true }),
|
||||
enabled: canManage,
|
||||
});
|
||||
|
||||
const resetForm = () => {
|
||||
setEditing(null);
|
||||
setLegalEntityId("");
|
||||
setBankInstitutionId("");
|
||||
setAccountName("");
|
||||
setAccountNumber("");
|
||||
};
|
||||
const refresh = () => queryClient.invalidateQueries({ queryKey: ["payment-accounts"] });
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
const payload = { legalEntityId, bankInstitutionId, accountName, accountNumber };
|
||||
return editing ? replacePaymentAccount(editing.id, payload) : createPaymentAccount(payload);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
toast.success(editing ? "企业银行账户已替换,历史账户已保留" : "企业银行账户已新增");
|
||||
resetForm();
|
||||
await refresh();
|
||||
},
|
||||
onError: (error) => toast.error(errorText(error)),
|
||||
});
|
||||
const disableMutation = useMutation({
|
||||
mutationFn: disablePaymentAccount,
|
||||
onSuccess: async () => {
|
||||
setDisableTarget(null);
|
||||
toast.success("企业银行账户已停用");
|
||||
await refresh();
|
||||
},
|
||||
onError: (error) => toast.error(errorText(error)),
|
||||
});
|
||||
const revealMutation = useMutation({
|
||||
mutationFn: () => revealPaymentAccount(revealTarget!.id, revealReason.trim()),
|
||||
onSuccess: ({ accountNumber: value }) => {
|
||||
setRevealedAccount(value);
|
||||
window.setTimeout(() => revealMutation.reset(), 0);
|
||||
},
|
||||
onError: (error) => toast.error(errorText(error)),
|
||||
});
|
||||
|
||||
const accounts = accountsQuery.data?.items ?? [];
|
||||
return (
|
||||
<PageScaffold>
|
||||
<PageHeader title="企业银行账户" subtitle="维护法人主体的付款来源账户;完整账号仅在填写原因后临时显示。" />
|
||||
<div className="mx-auto max-w-6xl space-y-5">
|
||||
{canManage ? (
|
||||
<SectionPanel title={editing ? "替换企业银行账户" : "新增企业银行账户"}>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="payment-account-legal-entity">法人主体</Label>
|
||||
<select id="payment-account-legal-entity" className="select-std w-full" value={legalEntityId} disabled={Boolean(editing)} onChange={(event) => setLegalEntityId(event.target.value)}>
|
||||
<option value="">请选择</option>
|
||||
{(legalEntitiesQuery.data?.items ?? []).map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="payment-account-bank">开户银行</Label>
|
||||
<select id="payment-account-bank" className="select-std w-full" value={bankInstitutionId} onChange={(event) => setBankInstitutionId(event.target.value)}>
|
||||
<option value="">请选择</option>
|
||||
{(banksQuery.data?.items ?? []).map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="payment-account-name">付款方账户名称</Label>
|
||||
<Input id="payment-account-name" value={accountName} onChange={(event) => setAccountName(event.target.value)} autoComplete="off" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="payment-account-number">付款方客户账号</Label>
|
||||
<Input id="payment-account-number" type="password" value={accountNumber} onChange={(event) => setAccountNumber(event.target.value)} autoComplete="new-password" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<Button type="button" disabled={saveMutation.isPending || !legalEntityId || !bankInstitutionId || !accountName.trim() || !accountNumber.trim()} onClick={() => saveMutation.mutate()}>{saveMutation.isPending ? "保存中..." : editing ? "确认替换" : "保存账户"}</Button>
|
||||
{editing ? <Button type="button" variant="secondary" onClick={resetForm}>取消替换</Button> : null}
|
||||
</div>
|
||||
</SectionPanel>
|
||||
) : null}
|
||||
|
||||
<SectionPanel title="账户记录">
|
||||
<div className="space-y-3">
|
||||
{accounts.map((account) => (
|
||||
<div key={account.id} className="detail-evidence-card flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="font-medium text-zinc-900">{account.legalEntity.name} · {account.accountName}</div>
|
||||
<div className="mt-1 text-sm text-zinc-600">{account.bankInstitution.name} · {account.accountNumberMasked ?? "-"}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">{account.enabled ? "当前启用" : "历史/已停用"}</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{canReveal ? <Button type="button" variant="secondary" onClick={() => { setRevealTarget(account); setRevealReason(""); setRevealedAccount(""); }}>查看完整账号</Button> : null}
|
||||
{canManage && account.enabled ? <Button type="button" variant="secondary" onClick={() => { setEditing(account); setLegalEntityId(account.legalEntity.id); setBankInstitutionId(account.bankInstitution.id); setAccountName(account.accountName); setAccountNumber(""); }}>替换</Button> : null}
|
||||
{canManage && account.enabled ? <Button type="button" variant="destructive" disabled={disableMutation.isPending} onClick={() => setDisableTarget(account)}>停用</Button> : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!accountsQuery.isLoading && !accounts.length ? <div className="text-sm text-zinc-500">尚未配置企业银行账户。</div> : null}
|
||||
</div>
|
||||
</SectionPanel>
|
||||
</div>
|
||||
|
||||
<Dialog open={Boolean(revealTarget)} onOpenChange={(open) => { if (!open) { setRevealTarget(null); setRevealReason(""); setRevealedAccount(""); } }}>
|
||||
<DialogContent className="max-w-md p-5">
|
||||
<DialogHeader><DialogTitle>查看完整企业银行账号</DialogTitle><DialogDescription>本次查看会记录查看人、目标账户、时间和原因。</DialogDescription></DialogHeader>
|
||||
{revealedAccount ? <div className="mt-4 rounded-lg border border-amber-200 bg-amber-50 p-3 font-mono text-sm text-amber-900 break-all">{revealedAccount}</div> : <div className="mt-4 space-y-2"><Label htmlFor="payment-account-reveal-reason">查看原因</Label><Textarea id="payment-account-reveal-reason" value={revealReason} onChange={(event) => setRevealReason(event.target.value)} maxLength={500} rows={3} /></div>}
|
||||
<DialogFooter><Button type="button" variant="secondary" onClick={() => { setRevealTarget(null); setRevealReason(""); setRevealedAccount(""); }}>关闭</Button>{!revealedAccount ? <Button type="button" disabled={revealReason.trim().length < 2 || revealMutation.isPending} onClick={() => revealMutation.mutate()}>{revealMutation.isPending ? "读取中..." : "确认查看"}</Button> : null}</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog open={Boolean(disableTarget)} onOpenChange={(open) => { if (!open) setDisableTarget(null); }}>
|
||||
<DialogContent className="max-w-md p-5">
|
||||
<DialogHeader><DialogTitle>停用企业银行账户</DialogTitle><DialogDescription>停用后不能为新付款批次生成制单;历史批次仍保留冻结账户摘要。确认停用 {disableTarget?.legalEntity.name ?? "该法人主体"} 的当前账户吗?</DialogDescription></DialogHeader>
|
||||
<DialogFooter><Button type="button" variant="secondary" onClick={() => setDisableTarget(null)}>返回</Button><Button type="button" variant="destructive" disabled={disableMutation.isPending} onClick={() => { if (disableTarget) disableMutation.mutate(disableTarget.id); }}>{disableMutation.isPending ? "停用中..." : "确认停用"}</Button></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</PageScaffold>
|
||||
);
|
||||
}
|
||||
|
||||
export default PaymentAccountsPage;
|
||||
在新工单中引用
屏蔽一个用户