实现企业银行账户与建行制单(corporate-bank-account-payment-order)
这个提交包含在:
@@ -0,0 +1,61 @@
|
||||
import { apiRequestData } from "./http";
|
||||
|
||||
export type BankInstitution = { id: string; code: string; name: string };
|
||||
|
||||
export type PaymentAccount = {
|
||||
id: string;
|
||||
legalEntity: { id: string; code: string; name: string };
|
||||
bankInstitution: BankInstitution;
|
||||
accountName: string;
|
||||
accountNumberMasked: string | null;
|
||||
enabled: boolean;
|
||||
archivedAt: string | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
export async function fetchBankInstitutions() {
|
||||
return apiRequestData<{ items: BankInstitution[] }>("/api/bank-institutions");
|
||||
}
|
||||
|
||||
export async function fetchPaymentAccounts() {
|
||||
return apiRequestData<{ items: PaymentAccount[] }>("/api/payment-accounts");
|
||||
}
|
||||
|
||||
export async function createPaymentAccount(payload: {
|
||||
legalEntityId: string;
|
||||
bankInstitutionId: string;
|
||||
accountName: string;
|
||||
accountNumber: string;
|
||||
}) {
|
||||
return apiRequestData<PaymentAccount>("/api/payment-accounts", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function replacePaymentAccount(accountId: string, payload: {
|
||||
legalEntityId: string;
|
||||
bankInstitutionId: string;
|
||||
accountName: string;
|
||||
accountNumber: string;
|
||||
}) {
|
||||
return apiRequestData<PaymentAccount>(`/api/payment-accounts/${accountId}/replace`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function disablePaymentAccount(accountId: string) {
|
||||
return apiRequestData<{ id: string; disabled: boolean }>(`/api/payment-accounts/${accountId}/disable`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function revealPaymentAccount(accountId: string, reason: string) {
|
||||
return apiRequestData<{ accountNumber: string }>(`/api/payment-accounts/${accountId}/reveal`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ reason }),
|
||||
});
|
||||
}
|
||||
@@ -49,6 +49,18 @@ export type PaymentBatch = {
|
||||
secondaryReviewComment: string | null;
|
||||
executionSummaryJson: Record<string, unknown> | null;
|
||||
receiptAttachmentCount?: number;
|
||||
paymentAccount: {
|
||||
accountName: string;
|
||||
bankName: string;
|
||||
bankInstitutionCode: string;
|
||||
accountNumberMasked: string;
|
||||
} | null;
|
||||
paymentOrderExportCount: number;
|
||||
paymentOrderLastExportedAt: string | null;
|
||||
paymentOrderLastExportedBy: string | null;
|
||||
canExportPaymentOrder: boolean;
|
||||
paymentOrderExportBlockReason: string | null;
|
||||
paymentOrderExportBlockers: Array<{ code: string; entityCode?: string }>;
|
||||
createdAt: string | null;
|
||||
createdBy: string | null;
|
||||
reviewedAt: string | null;
|
||||
@@ -171,6 +183,21 @@ export async function cancelPaymentBatch(batchId: string, reason: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function downloadPaymentOrder(batchId: string, reason: string): Promise<ApiBlobResponse> {
|
||||
return apiRequestBlob(`/api/payment-batches/${batchId}/payment-order`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ reason }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function revealPaymentBatchPayeeBank(batchId: string, itemId: string, reason: string) {
|
||||
return apiRequestData<{ bankAccount: string }>(
|
||||
`/api/payment-batches/${batchId}/items/${itemId}/payee-bank/reveal`,
|
||||
{ method: "POST", body: JSON.stringify({ reason }) },
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchPaymentBatchReceiptAttachments(batchId: string) {
|
||||
return apiRequestData<{ items: PaymentBatchReceiptAttachment[] }>(
|
||||
`/api/payment-batches/${batchId}/receipt-attachments`,
|
||||
|
||||
@@ -42,6 +42,11 @@ export type MeResponse = {
|
||||
status: string;
|
||||
notificationPushEnabled: boolean;
|
||||
roleCodes: string[];
|
||||
bankAccount: {
|
||||
bankName: string | null;
|
||||
bankInstitutionCode: string | null;
|
||||
accountNumberMasked: string | null;
|
||||
};
|
||||
profileCompleteness: ProfileCompleteness;
|
||||
wecomBinding?: {
|
||||
externalUserId: string;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { apiRequestData } from "./http";
|
||||
|
||||
export type EmployeeBankSummary = {
|
||||
bankInstitution: { id: string; code: string; name: string } | null;
|
||||
bankName: string | null;
|
||||
bankAccountMasked: string | null;
|
||||
};
|
||||
|
||||
export async function fetchUserSensitive(userId: string) {
|
||||
return apiRequestData<EmployeeBankSummary & { idNumber?: string | null }>(`/api/users/${userId}/sensitive`);
|
||||
}
|
||||
|
||||
export async function updateUserBank(userId: string, payload: {
|
||||
bankInstitutionId: string | null;
|
||||
bankAccount?: string | null;
|
||||
}) {
|
||||
return apiRequestData<{ id: string; updatedFields: string[] }>(`/api/users/${userId}/sensitive`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function revealUserBank(userId: string, reason: string) {
|
||||
return apiRequestData<{ bankAccount: string | null }>(`/api/users/${userId}/sensitive/bank/reveal`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ reason }),
|
||||
});
|
||||
}
|
||||
@@ -100,6 +100,9 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
|
||||
"asset.view_all",
|
||||
"attendance.view_all",
|
||||
"employee.bank.read",
|
||||
"payment.account.read",
|
||||
"payment.account.read_full",
|
||||
"payment.order.export",
|
||||
"payroll.manage",
|
||||
],
|
||||
finance_mgr: [
|
||||
@@ -125,7 +128,6 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
|
||||
"asset.inventory",
|
||||
"system.settings",
|
||||
"system.audit.view",
|
||||
"payment.batch.manage",
|
||||
"risk.review.manage",
|
||||
"project.view_all",
|
||||
"attendance.view_all",
|
||||
@@ -133,6 +135,10 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
|
||||
"attendance.sync",
|
||||
"payroll.manage",
|
||||
"employee.bank.read",
|
||||
"payment.account.manage",
|
||||
"payment.account.read",
|
||||
"payment.account.read_full",
|
||||
"payment.order.export",
|
||||
],
|
||||
administration: [
|
||||
"expense.proxy_submit",
|
||||
|
||||
@@ -27,7 +27,7 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
{ to: "/weekly-reports", label: "周报", icon: "article" },
|
||||
{ to: "/announcements", label: "公告", icon: "campaign" },
|
||||
{ to: "/projects", label: "项目", icon: "workspaces", anyOf: ["project.view_own", "project.view_all", "project.manage"] },
|
||||
{ to: "/system/settings", label: "管理", icon: "settings", anyOf: ["system.settings", "system.users", "masterdata.manage", "notification.channel.manage", "integration.manage", "system.audit.view"] },
|
||||
{ to: "/system/settings", label: "管理", icon: "settings", anyOf: ["system.settings", "system.users", "masterdata.manage", "notification.channel.manage", "integration.manage", "system.audit.view", "payment.account.read", "payment.account.manage"] },
|
||||
{ to: "/workflow/tasks", label: "待我处理", icon: "fact_check", anyOf: ["expense.approve", "leave.approve", "attendance.approve", "procurement.approve", "travel.approve"] },
|
||||
{ to: "/contracts-registry", label: "合同", icon: "description", anyOf: ["project.view_all", "project.manage", "system.settings"] },
|
||||
{ to: "/purchase-requests", label: "采购", icon: "shopping_cart", anyOf: ["procurement.create", "procurement.view_own", "procurement.view_all", "procurement.manage"] },
|
||||
|
||||
@@ -33,6 +33,8 @@ import {
|
||||
type WecomDirectoryState,
|
||||
} from "../api/employees";
|
||||
import { fetchEmployeePerformanceTimeline } from "../api/performance";
|
||||
import { fetchBankInstitutions } from "../api/paymentAccounts";
|
||||
import { fetchUserSensitive, revealUserBank, updateUserBank } from "../api/userSensitive";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasPermission } from "../auth/permissions";
|
||||
import { EmployeeAvatar } from "../components/common/EmployeeAvatar";
|
||||
@@ -187,6 +189,9 @@ export function EmployeeProfilePage() {
|
||||
const canManageSensitiveHr = hasPermission(permissions, "system.settings");
|
||||
const canManageEmployeeContracts = hasPermission(permissions, "employee.contract.manage");
|
||||
const canManagePayroll = hasPermission(permissions, "payroll.manage");
|
||||
const canManageBank = hasPermission(permissions, "employee.bank.manage");
|
||||
const canReadBank = canManageBank || hasPermission(permissions, "employee.bank.read") || me?.id === employeeId;
|
||||
const canRevealBank = canManageBank || hasPermission(permissions, "employee.bank.read");
|
||||
const canEditSelfProfile = me?.id === employeeId;
|
||||
const canEditBaseFields = canManageEmployeeProfile || canEditSelfProfile;
|
||||
const canEditExtendedFields = canManageEmployeeProfile;
|
||||
@@ -216,6 +221,21 @@ export function EmployeeProfilePage() {
|
||||
emergencyContactPhone: "",
|
||||
notes: "",
|
||||
});
|
||||
const [bankInstitutionId, setBankInstitutionId] = useState("");
|
||||
const [bankAccountInput, setBankAccountInput] = useState("");
|
||||
const [bankRevealReason, setBankRevealReason] = useState("");
|
||||
const [revealedBankAccount, setRevealedBankAccount] = useState("");
|
||||
|
||||
const bankQuery = useQuery({
|
||||
enabled: Boolean(employeeId) && canReadBank,
|
||||
queryKey: ["employee-bank", employeeId],
|
||||
queryFn: () => fetchUserSensitive(employeeId),
|
||||
});
|
||||
const bankInstitutionsQuery = useQuery({
|
||||
enabled: canManageBank,
|
||||
queryKey: ["bank-institutions"],
|
||||
queryFn: fetchBankInstitutions,
|
||||
});
|
||||
|
||||
const contractsQuery = useQuery({
|
||||
enabled: Boolean(employeeId) && canManageEmployeeContracts,
|
||||
@@ -265,6 +285,34 @@ export function EmployeeProfilePage() {
|
||||
});
|
||||
}, [profileQuery.data]);
|
||||
|
||||
useEffect(() => {
|
||||
setBankInstitutionId(bankQuery.data?.bankInstitution?.id ?? "");
|
||||
setBankAccountInput("");
|
||||
setBankRevealReason("");
|
||||
setRevealedBankAccount("");
|
||||
}, [bankQuery.data, employeeId]);
|
||||
|
||||
const saveBankMutation = useMutation({
|
||||
mutationFn: () => updateUserBank(employeeId, {
|
||||
bankInstitutionId: bankInstitutionId || null,
|
||||
...(bankAccountInput.trim() ? { bankAccount: bankAccountInput.trim() } : {}),
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
setBankAccountInput("");
|
||||
await queryClient.invalidateQueries({ queryKey: ["employee-bank", employeeId] });
|
||||
toast.success("收款信息已保存");
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error, "保存收款信息失败")),
|
||||
});
|
||||
const revealBankMutation = useMutation({
|
||||
mutationFn: () => revealUserBank(employeeId, bankRevealReason.trim()),
|
||||
onSuccess: ({ bankAccount }) => {
|
||||
setRevealedBankAccount(bankAccount ?? "");
|
||||
window.setTimeout(() => revealBankMutation.reset(), 0);
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error, "查看收款账号失败")),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setActiveTab(requestedTab);
|
||||
}, [employeeId, requestedTab]);
|
||||
@@ -914,6 +962,27 @@ export function EmployeeProfilePage() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{canReadBank ? (
|
||||
<div className="card-std space-y-4 p-6">
|
||||
<h3 className="text-base font-semibold text-zinc-900">收款信息</h3>
|
||||
<div className="grid gap-3 text-sm">
|
||||
<div><div className="text-zinc-500">开户银行</div><div className="mt-1 font-medium text-zinc-900">{bankQuery.data?.bankName ?? "未填写"}</div></div>
|
||||
<div><div className="text-zinc-500">银行账号</div><div className="mt-1 font-medium text-zinc-900">{bankQuery.data?.bankAccountMasked ?? "未填写"}</div></div>
|
||||
</div>
|
||||
{canManageBank ? (
|
||||
<div className="space-y-3 border-t border-zinc-100 pt-4">
|
||||
<div className="space-y-2"><label className="text-sm text-zinc-500">开户银行</label><select className="select-std w-full" value={bankInstitutionId} onChange={(event) => setBankInstitutionId(event.target.value)}><option value="">请选择</option>{(bankInstitutionsQuery.data?.items ?? []).map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></div>
|
||||
<div className="space-y-2"><label className="text-sm text-zinc-500">新银行账号</label><Input type="password" autoComplete="new-password" value={bankAccountInput} onChange={(event) => setBankAccountInput(event.target.value)} placeholder={bankQuery.data?.bankAccountMasked ? "不修改则留空" : "请输入完整账号"} /></div>
|
||||
<Button type="button" disabled={saveBankMutation.isPending || !bankInstitutionId || (!bankQuery.data?.bankAccountMasked && !bankAccountInput.trim())} onClick={() => saveBankMutation.mutate()}>{saveBankMutation.isPending ? "保存中..." : "保存收款信息"}</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{canRevealBank ? (
|
||||
<div className="space-y-2 border-t border-zinc-100 pt-4">
|
||||
{revealedBankAccount ? <><div className="rounded-lg border border-amber-200 bg-amber-50 p-3 font-mono text-sm break-all">{revealedBankAccount}</div><Button type="button" variant="secondary" onClick={() => { setRevealedBankAccount(""); setBankRevealReason(""); }}>隐藏完整账号</Button></> : <><label className="text-sm text-zinc-500">查看完整账号原因</label><Textarea rows={2} maxLength={500} value={bankRevealReason} onChange={(event) => setBankRevealReason(event.target.value)} /><Button type="button" variant="secondary" disabled={bankRevealReason.trim().length < 2 || revealBankMutation.isPending} onClick={() => revealBankMutation.mutate()}>查看完整账号</Button></>}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="card-std space-y-4 p-6">
|
||||
<h3 className="text-base font-semibold text-zinc-900">离职阻塞项</h3>
|
||||
<div className="rounded-lg border border-zinc-200 p-3 text-sm">
|
||||
|
||||
@@ -239,6 +239,13 @@ export function MobileProfilePage() {
|
||||
</div>
|
||||
</SectionPanel>
|
||||
|
||||
<SectionPanel title="收款信息">
|
||||
<dl className="grid gap-2 text-sm">
|
||||
<div className="mobile-subcard"><dt className="text-xs text-zinc-500">开户银行</dt><dd className="mt-1 font-medium text-zinc-900">{me?.bankAccount?.bankName ?? "未填写"}</dd></div>
|
||||
<div className="mobile-subcard"><dt className="text-xs text-zinc-500">银行账号</dt><dd className="mt-1 font-medium text-zinc-900">{me?.bankAccount?.accountNumberMasked ?? "未填写"}</dd></div>
|
||||
</dl>
|
||||
</SectionPanel>
|
||||
|
||||
<SectionPanel title="组织与角色摘要">
|
||||
<details className="mobile-list-card text-sm">
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between gap-3">
|
||||
|
||||
@@ -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;
|
||||
@@ -5,12 +5,14 @@ import {
|
||||
cancelPaymentBatch,
|
||||
createPaymentBatch,
|
||||
downloadPaymentBatchReceiptAttachment,
|
||||
downloadPaymentOrder,
|
||||
executePaymentBatch,
|
||||
fetchPaymentBatchCandidates,
|
||||
fetchPaymentBatchDetail,
|
||||
fetchPaymentBatchReceiptAttachments,
|
||||
fetchPaymentBatches,
|
||||
reviewPaymentBatch,
|
||||
revealPaymentBatchPayeeBank,
|
||||
updatePaymentBatchItem,
|
||||
uploadPaymentBatchReceiptAttachments,
|
||||
voidPaymentBatchReceiptAttachment,
|
||||
@@ -92,6 +94,24 @@ function resolveError(error: unknown) {
|
||||
: "操作失败";
|
||||
}
|
||||
|
||||
function paymentOrderBlockerLabel(blocker: { code: string; entityCode?: string }) {
|
||||
const subject = blocker.entityCode ? `${blocker.entityCode}:` : "";
|
||||
const messages: Record<string, string> = {
|
||||
status_not_reviewed: "仅待财务付款批次可生成制单",
|
||||
reimbursement_only: "仅报销付款批次支持制单",
|
||||
bank_channel_required: "付款渠道不是银行转账",
|
||||
legacy_payment_account_snapshot_missing: "历史批次未冻结公司付款账户,仅支持人工付款",
|
||||
payment_account_snapshot_invalid: "冻结的公司付款账户资料不完整",
|
||||
payer_bank_institution_missing: "公司付款账户缺少规范化开户银行",
|
||||
payer_bank_not_supported: "公司付款账户不是建设银行",
|
||||
items_missing: "批次没有可制单的报销明细",
|
||||
legacy_payee_snapshot_missing: "未冻结完整收款账号,仅支持人工付款",
|
||||
payee_bank_institution_missing: "未冻结规范化开户银行,仅支持人工付款",
|
||||
payee_bank_not_supported: "收款账户不是建设银行,首版请人工转账",
|
||||
};
|
||||
return `${subject}${messages[blocker.code] ?? "制单资料不完整,请联系管理员核对"}`;
|
||||
}
|
||||
|
||||
export function PaymentBatchesPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { me, permissions } = useAuth();
|
||||
@@ -100,6 +120,12 @@ export function PaymentBatchesPage() {
|
||||
const canReviewBatch =
|
||||
hasPermission(permissions, "payment.batch.review") &&
|
||||
(me?.roleCodes.includes("manager") || me?.roleCodes.includes("ceo"));
|
||||
const canExportPaymentOrder =
|
||||
hasPermission(permissions, "payment.order.export") &&
|
||||
Boolean(me?.roleCodes.some((roleCode) => ["finance", "finance_mgr"].includes(roleCode)));
|
||||
const canRevealPayeeBank =
|
||||
hasPermission(permissions, "employee.bank.read") &&
|
||||
Boolean(me?.roleCodes.some((roleCode) => ["finance", "finance_mgr"].includes(roleCode)));
|
||||
const [candidateSearch, setCandidateSearch] = useState("");
|
||||
const [selectedCandidateIds, setSelectedCandidateIds] = useState<string[]>([]);
|
||||
const [selectedPaymentChannel, setSelectedPaymentChannel] = useState<PaymentChannel>("bank");
|
||||
@@ -116,9 +142,12 @@ export function PaymentBatchesPage() {
|
||||
const [reasonAction, setReasonAction] = useState<
|
||||
| { kind: "cancel"; batchId: string; batchNo: string }
|
||||
| { kind: "voidReceipt"; batchId: string; batchNo: string; attachmentId: string; attachmentName: string }
|
||||
| { kind: "paymentOrder"; batchId: string; batchNo: string; reexport: boolean }
|
||||
| { kind: "payeeBank"; batchId: string; batchNo: string; itemId: string; entityCode: string }
|
||||
| null
|
||||
>(null);
|
||||
const [actionReason, setActionReason] = useState("");
|
||||
const [revealedPayeeAccount, setRevealedPayeeAccount] = useState("");
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: ["payment-batches"],
|
||||
@@ -357,6 +386,37 @@ export function PaymentBatchesPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const paymentOrderMutation = useMutation({
|
||||
mutationFn: ({ batchId, reason }: { batchId: string; reason: string }) =>
|
||||
downloadPaymentOrder(batchId, reason),
|
||||
onSuccess: async (response) => {
|
||||
downloadBlob(response.blob, response.filename ?? "payment-order.xls");
|
||||
setReasonAction(null);
|
||||
setActionReason("");
|
||||
await refresh();
|
||||
toast.success("银行制单已生成,请导入企业网银后核对结果");
|
||||
},
|
||||
onError: (err) => {
|
||||
const message = resolveError(err);
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const revealPayeeBankMutation = useMutation({
|
||||
mutationFn: ({ batchId, itemId, reason }: { batchId: string; itemId: string; reason: string }) =>
|
||||
revealPaymentBatchPayeeBank(batchId, itemId, reason),
|
||||
onSuccess: ({ bankAccount }) => {
|
||||
setRevealedPayeeAccount(bankAccount);
|
||||
window.setTimeout(() => revealPayeeBankMutation.reset(), 0);
|
||||
},
|
||||
onError: (err) => {
|
||||
const message = resolveError(err);
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<ListWorkspace>
|
||||
{downloadReasonDialog}
|
||||
@@ -507,6 +567,18 @@ export function PaymentBatchesPage() {
|
||||
复核通过
|
||||
</Button>
|
||||
) : null}
|
||||
{selectedBatch.status === "reviewed" && selectedBatch.canExportPaymentOrder && canExportPaymentOrder && selectedBatch.paymentOrderExportCount === 0 ? (
|
||||
<Button
|
||||
type="button"
|
||||
disabled={paymentOrderMutation.isPending}
|
||||
onClick={() => {
|
||||
setActionReason("");
|
||||
setReasonAction({ kind: "paymentOrder", batchId: selectedBatch.id, batchNo: selectedBatch.batchNo, reexport: false });
|
||||
}}
|
||||
>
|
||||
{paymentOrderMutation.isPending ? "生成中..." : "下载银行制单"}
|
||||
</Button>
|
||||
) : null}
|
||||
{needsBankReceipt ? (
|
||||
<Button
|
||||
type="button"
|
||||
@@ -548,16 +620,41 @@ export function PaymentBatchesPage() {
|
||||
取消
|
||||
</Button>
|
||||
) : null}
|
||||
{selectedBatch.status === "reviewed" && selectedBatch.canExportPaymentOrder && canExportPaymentOrder && selectedBatch.paymentOrderExportCount > 0 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={paymentOrderMutation.isPending}
|
||||
onClick={() => {
|
||||
setActionReason("");
|
||||
setReasonAction({ kind: "paymentOrder", batchId: selectedBatch.id, batchNo: selectedBatch.batchNo, reexport: true });
|
||||
}}
|
||||
>
|
||||
重新下载制单
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="detail-fact-grid text-sm xl:grid-cols-6">
|
||||
<div className="detail-fact-grid text-sm xl:grid-cols-4">
|
||||
<div className="detail-fact-card"><div className="detail-fact-card__label">状态</div><div className="detail-fact-card__value">{paymentBatchStatusLabel(selectedBatch.status)}</div></div>
|
||||
<div className="detail-fact-card"><div className="detail-fact-card__label">实体</div><div className="detail-fact-card__value">{entityTypeLabel(selectedBatch.entityType)}</div></div>
|
||||
<div className="detail-fact-card"><div className="detail-fact-card__label">总金额</div><div className="detail-fact-card__value">{formatMoney(selectedBatch.totalAmount)}</div></div>
|
||||
<div className="detail-fact-card"><div className="detail-fact-card__label">默认日期</div><div className="detail-fact-card__value">{formatDisplayDate(selectedBatch.defaultPaymentDate)}</div></div>
|
||||
<div className="detail-fact-card"><div className="detail-fact-card__label">默认渠道</div><div className="detail-fact-card__value">{paymentChannelLabel(selectedBatch.defaultPaymentChannel)}</div></div>
|
||||
<div className="detail-fact-card"><div className="detail-fact-card__label">复核进度</div><div className="detail-fact-card__value">{selectedBatch.reviewRequirement.completedReviewCount}/{selectedBatch.reviewRequirement.requiredReviewCount}</div></div>
|
||||
<div className="detail-fact-card"><div className="detail-fact-card__label">付款账户</div><div className="detail-fact-card__value">{selectedBatch.paymentAccount ? `${selectedBatch.paymentAccount.accountName} · ${selectedBatch.paymentAccount.accountNumberMasked}` : "未冻结(仅支持人工付款)"}</div></div>
|
||||
<div className="detail-fact-card"><div className="detail-fact-card__label">银行制单</div><div className="detail-fact-card__value">{selectedBatch.paymentOrderExportCount > 0 ? `已生成 ${selectedBatch.paymentOrderExportCount} 次` : "尚未生成"}</div></div>
|
||||
</div>
|
||||
{selectedBatch.status === "reviewed" && selectedBatch.paymentOrderExportBlockers.length > 0 ? (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-3 text-sm text-amber-900">
|
||||
<div className="font-medium">当前批次不能生成银行制单,可继续人工付款:</div>
|
||||
<ul className="mt-1 list-disc space-y-1 pl-5">
|
||||
{selectedBatch.paymentOrderExportBlockers.map((blocker, index) => (
|
||||
<li key={`${blocker.code}-${blocker.entityCode ?? "batch"}-${index}`}>{paymentOrderBlockerLabel(blocker)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="finance-command-panel space-y-3" data-testid="payment-batch-current-processing">
|
||||
@@ -582,8 +679,10 @@ export function PaymentBatchesPage() {
|
||||
? "复核通过"
|
||||
: selectedBatch.status === "reviewed" && requiresBankReceipt && !receiptAttachmentsQuery.isSuccess
|
||||
? "核对银行回单状态"
|
||||
: selectedBatch.status === "reviewed" && needsBankReceipt
|
||||
? "线下付款并上传银行回单"
|
||||
: selectedBatch.status === "reviewed" && needsBankReceipt
|
||||
? selectedBatch.paymentOrderExportCount > 0
|
||||
? "制单已生成,完成付款并上传银行回单"
|
||||
: "线下付款并上传银行回单"
|
||||
: selectedBatch.status === "reviewed"
|
||||
? "登记付款完成"
|
||||
: selectedBatch.status === "failed"
|
||||
@@ -598,7 +697,9 @@ export function PaymentBatchesPage() {
|
||||
: selectedBatch.status === "reviewed" && requiresBankReceipt && !receiptAttachmentsQuery.isSuccess
|
||||
? "付款复核已通过,正在核对银行回单状态。"
|
||||
: selectedBatch.status === "reviewed" && needsBankReceipt
|
||||
? "付款复核已通过。请财务先在线下银行完成付款,再上传银行回单。"
|
||||
? selectedBatch.paymentOrderExportCount > 0
|
||||
? "已生成银行制单,等待付款完成并上传回单。"
|
||||
: "付款复核已通过。等待财务线下付款并上传回单。"
|
||||
: selectedBatch.status === "reviewed"
|
||||
? "付款复核已通过。请财务核对付款结果并登记付款完成。"
|
||||
: selectedBatch.status === "executed"
|
||||
@@ -626,7 +727,7 @@ export function PaymentBatchesPage() {
|
||||
<div className="mt-3 detail-fact-grid text-sm md:grid-cols-4">
|
||||
<div className="detail-fact-card"><div className="detail-fact-card__label">收款人</div><div className="detail-fact-card__value">{item.payeeName || "-"}</div></div>
|
||||
<div className="detail-fact-card"><div className="detail-fact-card__label">开户银行</div><div className="detail-fact-card__value">{item.bankName || "-"}</div></div>
|
||||
<div className="detail-fact-card"><div className="detail-fact-card__label">银行账户</div><div className="detail-fact-card__value">{item.bankAccountMasked || "-"}</div></div>
|
||||
<div className="detail-fact-card"><div className="detail-fact-card__label">银行账户</div><div className="detail-fact-card__value">{item.bankAccountMasked || "-"}</div>{canRevealPayeeBank && ["reviewed", "failed"].includes(selectedBatch.status) && selectedBatch.paymentAccount ? <Button type="button" size="sm" variant="secondary" className="mt-2" onClick={() => { setActionReason(""); setRevealedPayeeAccount(""); setReasonAction({ kind: "payeeBank", batchId: selectedBatch.id, batchNo: selectedBatch.batchNo, itemId: item.id, entityCode: item.entityCode }); }}>查看完整账号</Button> : null}</div>
|
||||
<div className="detail-fact-card"><div className="detail-fact-card__label">付款渠道</div><div className="detail-fact-card__value">{paymentChannelLabel(item.paymentChannelOverride ?? selectedBatch.defaultPaymentChannel)}</div></div>
|
||||
</div>
|
||||
{["reviewed", "failed"].includes(selectedBatch.status) ? (
|
||||
@@ -896,34 +997,43 @@ export function PaymentBatchesPage() {
|
||||
if (!open) {
|
||||
setReasonAction(null);
|
||||
setActionReason("");
|
||||
setRevealedPayeeAccount("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-md p-5">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{reasonAction?.kind === "voidReceipt" ? "作废银行回单" : "取消付款批次"}</DialogTitle>
|
||||
<DialogTitle>{reasonAction?.kind === "voidReceipt" ? "作废银行回单" : reasonAction?.kind === "paymentOrder" ? (reasonAction.reexport ? "重新下载银行制单" : "下载银行制单") : reasonAction?.kind === "payeeBank" ? "查看收款完整账号" : "取消付款批次"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{reasonAction?.kind === "voidReceipt"
|
||||
? `回单:${reasonAction.attachmentName}。文件记录会保留并标记为已作废。`
|
||||
: reasonAction?.kind === "paymentOrder"
|
||||
? reasonAction.reexport
|
||||
? `批次:${reasonAction.batchNo}。重新下载不会作废之前的文件。如果上一份已在网银导入,请勿重复提交。`
|
||||
: `批次:${reasonAction.batchNo}。文件包含完整银行账号,仅用于导入企业网银。`
|
||||
: reasonAction?.kind === "payeeBank"
|
||||
? `报销单:${reasonAction.entityCode}。本次查看会记录查看人、批次项、时间和原因。`
|
||||
: reasonAction
|
||||
? `批次:${reasonAction.batchNo}。只取消尚未执行的付款项,已执行项不会回滚。`
|
||||
: "请填写原因。"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="mt-4 space-y-2">
|
||||
{reasonAction?.kind === "payeeBank" && revealedPayeeAccount ? (
|
||||
<div className="mt-4 rounded-lg border border-amber-200 bg-amber-50 p-3 font-mono text-sm text-amber-900 break-all">{revealedPayeeAccount}</div>
|
||||
) : <div className="mt-4 space-y-2">
|
||||
<Textarea
|
||||
value={actionReason}
|
||||
onChange={(event) => setActionReason(event.target.value)}
|
||||
aria-label={reasonAction?.kind === "voidReceipt" ? "回单作废原因" : "批次取消原因"}
|
||||
aria-label={reasonAction?.kind === "voidReceipt" ? "回单作废原因" : reasonAction?.kind === "paymentOrder" ? "制单下载原因" : reasonAction?.kind === "payeeBank" ? "查看收款账号原因" : "批次取消原因"}
|
||||
rows={4}
|
||||
maxLength={500}
|
||||
placeholder={reasonAction?.kind === "voidReceipt" ? "说明回单为何作废" : "说明批次为何不再继续"}
|
||||
placeholder={reasonAction?.kind === "voidReceipt" ? "说明回单为何作废" : reasonAction?.kind === "paymentOrder" ? "说明本次制单用途" : reasonAction?.kind === "payeeBank" ? "说明查看完整账号的业务用途" : "说明批次为何不再继续"}
|
||||
/>
|
||||
<div className="flex items-center justify-between text-xs text-zinc-500">
|
||||
<span>原因需填写 2–500 个字符,并写入审计记录。</span>
|
||||
<span>{actionReason.trim().length}/500</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => {
|
||||
setReasonAction(null);
|
||||
@@ -931,19 +1041,25 @@ export function PaymentBatchesPage() {
|
||||
}}>
|
||||
返回
|
||||
</Button>
|
||||
<Button
|
||||
{!(reasonAction?.kind === "payeeBank" && revealedPayeeAccount) ? <Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
variant={reasonAction?.kind === "paymentOrder" ? "default" : "destructive"}
|
||||
disabled={
|
||||
actionReason.trim().length < 2 ||
|
||||
cancelMutation.isPending ||
|
||||
voidReceiptMutation.isPending
|
||||
voidReceiptMutation.isPending ||
|
||||
paymentOrderMutation.isPending ||
|
||||
revealPayeeBankMutation.isPending
|
||||
}
|
||||
onClick={() => {
|
||||
if (!reasonAction) return;
|
||||
const reason = actionReason.trim();
|
||||
if (reasonAction.kind === "cancel") {
|
||||
cancelMutation.mutate({ batchId: reasonAction.batchId, reason });
|
||||
} else if (reasonAction.kind === "paymentOrder") {
|
||||
paymentOrderMutation.mutate({ batchId: reasonAction.batchId, reason });
|
||||
} else if (reasonAction.kind === "payeeBank") {
|
||||
revealPayeeBankMutation.mutate({ batchId: reasonAction.batchId, itemId: reasonAction.itemId, reason });
|
||||
} else {
|
||||
voidReceiptMutation.mutate({
|
||||
batchId: reasonAction.batchId,
|
||||
@@ -953,8 +1069,8 @@ export function PaymentBatchesPage() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
{reasonAction?.kind === "voidReceipt" ? "确认作废" : "确认取消"}
|
||||
</Button>
|
||||
{reasonAction?.kind === "voidReceipt" ? "确认作废" : reasonAction?.kind === "paymentOrder" ? "确认下载" : reasonAction?.kind === "payeeBank" ? "确认查看" : "确认取消"}
|
||||
</Button> : null}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -52,7 +52,7 @@ const PROCUREMENT_PORTAL_PERMISSIONS = ["procurement.view_own", "procurement.vie
|
||||
const TRAVEL_PORTAL_PERMISSIONS = ["travel.view_own", "travel.view_all", "travel.create", "travel.submit"];
|
||||
const APPROVAL_PORTAL_PERMISSIONS = ["expense.approve", "leave.approve", "procurement.approve", "travel.approve"];
|
||||
const FINANCE_PORTAL_PERMISSIONS = ["payment.batch.manage", "risk.review.manage", "expense.finance.pay"];
|
||||
const SYSTEM_PORTAL_PERMISSIONS = ["system.users", "system.settings", "system.audit.view", "notification.channel.manage"];
|
||||
const SYSTEM_PORTAL_PERMISSIONS = ["system.users", "system.settings", "system.audit.view", "notification.channel.manage", "payment.account.read", "payment.account.manage"];
|
||||
const CONTRACT_PORTAL_PERMISSIONS = ["project.view_all", "project.manage", "system.settings"];
|
||||
const PROJECT_PORTAL_PERMISSIONS = ["project.view_own", "project.view_all", "project.manage"];
|
||||
const ASSET_PORTAL_PERMISSIONS = ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"];
|
||||
|
||||
@@ -134,6 +134,14 @@ export function ProfilePage() {
|
||||
</dl>
|
||||
</SectionPanel>
|
||||
|
||||
<SectionPanel title="收款信息">
|
||||
<dl className="grid gap-4 text-sm text-zinc-700 md:grid-cols-2">
|
||||
<div><dt className="text-zinc-500">开户银行</dt><dd className="mt-1 font-medium text-zinc-900">{me?.bankAccount?.bankName ?? "未填写"}</dd></div>
|
||||
<div><dt className="text-zinc-500">银行账号</dt><dd className="mt-1 font-medium text-zinc-900">{me?.bankAccount?.accountNumberMasked ?? "未填写"}</dd></div>
|
||||
</dl>
|
||||
{!me?.profileCompleteness.hasBankAccount ? <p className="mt-3 text-sm text-amber-700">收款信息尚未填写,请联系 HR 或管理员补充。</p> : null}
|
||||
</SectionPanel>
|
||||
|
||||
<SectionPanel title="账号安全">
|
||||
<div className="flex flex-col gap-3 text-sm text-zinc-600 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
|
||||
@@ -24,6 +24,7 @@ const ITEMS: ManagementItem[] = [
|
||||
{ to: "/system/workspace", icon: "dashboard_customize", title: "工作区", description: "菜单入口、模块显隐和小团队基线。", group: "系统与审计", anyOf: ["system.settings"] },
|
||||
{ to: "/audit-logs", icon: "manage_search", title: "审计日志", description: "关键操作留痕和排障。", group: "系统与审计", anyOf: ["system.audit.view"] },
|
||||
{ to: "/system/master-data", icon: "dataset", title: "主数据", description: "法体、成本中心、供应商等基础数据。", group: "功能管理", anyOf: ["masterdata.view", "masterdata.manage"] },
|
||||
{ to: "/system/payment-accounts", icon: "account_balance", title: "企业银行账户", description: "维护付款来源账户、脱敏查看与变更留痕。", group: "功能管理", anyOf: ["payment.account.read", "payment.account.manage"] },
|
||||
{ to: "/system/notification-channels", icon: "notifications_active", title: "通知渠道", description: "企业微信、邮件等补充通知。", group: "功能管理", anyOf: ["notification.channel.manage"] },
|
||||
{ to: "/system/feature-flags", icon: "toggle_on", title: "功能管理", description: "功能开关、灰度和试运行边界。", group: "功能管理", anyOf: ["system.settings"] },
|
||||
];
|
||||
|
||||
+11
-1
@@ -48,6 +48,7 @@ import {
|
||||
PayrollPage,
|
||||
PerformancePage,
|
||||
PaymentBatchesPage,
|
||||
PaymentAccountsPage,
|
||||
PeoplePage,
|
||||
PeopleStatsPage,
|
||||
PortalAnnouncementsPage,
|
||||
@@ -582,6 +583,15 @@ const routeElements = (
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="system/payment-accounts"
|
||||
element={
|
||||
<RequirePermission anyOf={["payment.account.read", "payment.account.manage"]}>
|
||||
{withRouteFallback(<PaymentAccountsPage />)}
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="system/notification-channels"
|
||||
element={
|
||||
@@ -603,7 +613,7 @@ const routeElements = (
|
||||
<Route
|
||||
path="system/settings"
|
||||
element={
|
||||
<RequirePermission anyOf={["system.settings", "system.users", "masterdata.manage", "notification.channel.manage", "integration.manage"]}>
|
||||
<RequirePermission anyOf={["system.settings", "system.users", "masterdata.manage", "notification.channel.manage", "integration.manage", "payment.account.read", "payment.account.manage"]}>
|
||||
{withRouteFallback(<SystemSettingsPage />)}
|
||||
</RequirePermission>
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { PaymentAccountsPage as default } from "../../pages/PaymentAccountsPage";
|
||||
@@ -30,6 +30,7 @@ export const NotificationChannelsPage = lazy(() => import("./NotificationChannel
|
||||
export const NotificationsPage = lazy(() => import("./NotificationsPage"));
|
||||
export const AnnouncementsPage = lazy(() => import("./AnnouncementsPage"));
|
||||
export const PaymentBatchesPage = lazy(() => import("./PaymentBatchesPage"));
|
||||
export const PaymentAccountsPage = lazy(() => import("./PaymentAccountsPage"));
|
||||
export const PeoplePage = lazy(() => import("./PeoplePage"));
|
||||
export const PeopleStatsPage = lazy(() => import("./PeopleStatsPage"));
|
||||
export const PerformancePage = lazy(() => import("./PerformancePage"));
|
||||
|
||||
在新工单中引用
屏蔽一个用户