222 行
12 KiB
TypeScript
222 行
12 KiB
TypeScript
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
|
|
import { useEffect, useState } from "react";
|
|
import { Link } from "react-router-dom";
|
|
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 { useAuth } from "../auth/AuthContext";
|
|
import { hasAnyPermission, hasPermission } from "../auth/permissions";
|
|
import { PageHeader } from "../components/common/PageHeader";
|
|
import { resolveSingleCompanyState } from "../features/company/singleCompany";
|
|
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 resolveUserErrorMessage(error, "操作失败");
|
|
}
|
|
|
|
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 canManageCompanyInformation = hasAnyPermission(permissions, ["masterdata.view", "masterdata.manage"]);
|
|
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 activeCompanies = legalEntitiesQuery.data?.items ?? [];
|
|
const companyState = resolveSingleCompanyState(activeCompanies);
|
|
const singleCompany = companyState.kind === "single" ? companyState.company : null;
|
|
|
|
useEffect(() => {
|
|
if (!editing && singleCompany) {
|
|
setLegalEntityId(singleCompany.id);
|
|
}
|
|
}, [editing, singleCompany]);
|
|
|
|
const resetForm = () => {
|
|
setEditing(null);
|
|
setLegalEntityId(singleCompany?.id ?? "");
|
|
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 && legalEntitiesQuery.isLoading ? (
|
|
<SectionPanel title="企业银行账户">
|
|
<div className="text-sm text-zinc-500">正在读取公司信息...</div>
|
|
</SectionPanel>
|
|
) : null}
|
|
|
|
{canManage && legalEntitiesQuery.isError ? (
|
|
<SectionPanel title="企业银行账户">
|
|
<div className="text-sm text-red-600">公司信息读取失败,请刷新后重试。</div>
|
|
</SectionPanel>
|
|
) : null}
|
|
|
|
{canManage && !legalEntitiesQuery.isLoading && !legalEntitiesQuery.isError && companyState.kind === "empty" ? (
|
|
<SectionPanel title="先完善公司信息">
|
|
<div className="space-y-3 text-sm text-zinc-600">
|
|
<p>企业银行账户必须关联当前公司。请先完善公司信息,再回来录入付款账户。</p>
|
|
{canManageCompanyInformation ? (
|
|
<Button asChild type="button" variant="secondary">
|
|
<Link to="/system/master-data">完善公司信息</Link>
|
|
</Button>
|
|
) : (
|
|
<p>请联系管理员先完善公司信息。</p>
|
|
)}
|
|
</div>
|
|
</SectionPanel>
|
|
) : null}
|
|
|
|
{canManage && companyState.kind === "multiple" ? (
|
|
<SectionPanel title="公司信息需要处理">
|
|
<div className="space-y-3 text-sm text-red-600">
|
|
<p>检测到多条启用的公司信息,系统不会猜测付款账户应关联哪一条。请先完善公司信息。</p>
|
|
{canManageCompanyInformation ? (
|
|
<Button asChild type="button" variant="secondary">
|
|
<Link to="/system/master-data">查看公司信息</Link>
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</SectionPanel>
|
|
) : null}
|
|
|
|
{canManage && singleCompany ? (
|
|
<SectionPanel title={editing ? "替换企业银行账户" : "新增企业银行账户"}>
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<Label>当前公司</Label>
|
|
<div className="input-std flex min-h-10 items-center bg-zinc-50 text-sm text-zinc-700" data-testid="payment-account-company">
|
|
{editing?.legalEntity.name ?? singleCompany.name}
|
|
</div>
|
|
</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;
|