借款出借人线上确认借款并以站内通知直达(09-17-company-loan-principal-repayment)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

- 新增出借人确认声明:财务发起,出借人本人查看原件后确认或退回;快照与决定不可改删,金额、日期、原件或身份变化后整组失效
- 余额确认接受纸质协议或有效线上声明二选一,代账余额核对材料仍必需;声明与公司代表授权分离
- 普通员工无常驻入口,财务发起后仅通知对应出借人并直达确认页
- 新增迁移 20261010010000_loan_lender_acknowledgements(仅新增三表)
- 测试:单测、隔离 HTTP/浏览器、完整借款回归;独立只读复核无阻断;同步发布记录与操作手册

Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
2026-10-10 15:22:12 +08:00
共同撰写人 Claude Opus 5.5
父节点 2063ff1e93
当前提交 4a5cd8f2b3
共修改 34 个文件,包含 1610 行新增和 41 行删除
+24 -2
查看文件
@@ -16,6 +16,8 @@ export type CompanyLoan = {
balanceAsOf: string; historicalEntry: boolean; confirmationStatus: string; version: number;
outstanding: string; available: string; reserved: string; settled: string;
evidences: LoanEvidence[]; accountingConfirmationRef: string | null;
lenderAcknowledgement?: { id: string; confirmedAt: string; lenderName: string } | null;
lenderAcknowledgementRenewable?: boolean;
};
export type LoanRepayment = LoanReconciliation & {
approvalMode?: "independent" | "related_party"; authorizedApproverId?: string | null;
@@ -101,8 +103,28 @@ export function uploadLoanEvidence(id: string, kind: string, file: File) {
const body = new FormData(); body.append("file", file);
return apiRequestData<LoanEvidence>(`/api/company-loans/${id}/evidences/${kind}`, { method: "POST", body });
}
export function downloadLoanEvidence(id: string, evidenceId: string, reason: string, paymentBatchId?: string, authorizationId?: string) {
return apiRequestBlob(`/api/company-loans/${id}/evidences/${evidenceId}/download`, { method: "POST", body: JSON.stringify({ reason, paymentBatchId, authorizationId }) });
export function downloadLoanEvidence(id: string, evidenceId: string, reason: string, paymentBatchId?: string, authorizationId?: string,
lenderAcknowledgementId?: string, signal?: AbortSignal) {
return apiRequestBlob(`/api/company-loans/${id}/evidences/${evidenceId}/download`, { method: "POST", signal,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reason, paymentBatchId, authorizationId, lenderAcknowledgementId }) });
}
export type LoanLenderAcknowledgement = {
id: string; lenderId: string; legalEntityId: string; lenderName: string; companyName: string;
proposedByName: string; proposedAt: string; reason: string; contentHash: string;
current: boolean; invalidatedAt: string | null; canDecide: boolean; canReadOriginals: boolean;
status: "pending" | "confirmed" | "returned" | "invalid";
decision: { action: "confirm" | "return"; reason: string; createdAt: string; lenderName: string } | null;
loans: Array<{ loanId: string; loanNo: string; lenderId: string; legalEntityId: string; originReference: string;
originalPrincipal: string; openingOutstandingPrincipal: string; receivedDate: string; balanceAsOf: string;
originals: Array<{ id: string; sha256: string; originalName: string; fileSize: number; mimeType: string }> }>;
};
export function fetchLenderAcknowledgements(page: number, signal?: AbortSignal) {
return apiRequestData<{ items: LoanLenderAcknowledgement[]; total: number }>(`/api/loan-lender-acknowledgements?page=${page}&pageSize=30`, { signal });
}
export function fetchLenderAcknowledgement(id: string, signal?: AbortSignal) {
return apiRequestData<LoanLenderAcknowledgement>(`/api/loan-lender-acknowledgements/${id}`, { signal });
}
export type LoanRelatedAuthorization = {
+5 -1
查看文件
@@ -29,7 +29,11 @@ export const ERROR_REASON_MESSAGES: Record<string, string> = {
"loan.account_unavailable": "借款收款资料暂不可用,请联系财务核对账户加密配置。",
"loan.duplicate_registration": "该借入凭据已登记,或此请求已使用,请查看原记录,勿重复登记。",
"loan.confirmed_immutable": "借款余额已确认,不能覆盖修改;请保留原记录并发起纠错核查。",
"loan.evidence_required": "请先上传协议、原借入凭据和余额核对材料;期初余额低于原本金还须补齐系统外还款依据。",
"loan.lender_ack_scope_invalid": "请选择同一公司、同一已关联员工出借人的待核实借款或声明已失效的借款,并先上传原借入凭据;经办人与出借人须分开。",
"loan.lender_ack_exists": "所选借款已有待确认或有效的出借人声明,请查看现有记录。",
"loan.lender_ack_changed": "借款内容、原件或出借人资料已变化,请由财务重新发起确认。",
"loan.lender_ack_decided": "这份借款声明已经处理,请查看确认记录;退回后可由财务重新发起。",
"loan.evidence_required": "请提供协议或有效的出借人在线确认,并上传原借入凭据和余额核对材料;期初余额低于原本金还须补齐系统外还款依据。",
"loan.evidence_invalid": "借款材料不符合要求,请使用有效的 PDF 或图片文件。",
"loan.balance_unconfirmed": "借款余额尚未核实,不能申请归还本金。",
"loan.allocation_invalid": "分配金额超过可还本金,或借款不属于同一公司和出借人,请重新核对。",
+1
查看文件
@@ -35,6 +35,7 @@ const MASTERDATA_VIEW_PERMISSIONS = ["masterdata.view"] as const;
const MASTERDATA_MANAGE_PERMISSIONS = ["masterdata.manage"] as const;
const EMPLOYEE_BASE_PERMISSIONS = [
"loan.acknowledge_own",
...EXPENSE_SELF_SERVICE_PERMISSIONS,
...LEAVE_SELF_SERVICE_PERMISSIONS,
...ATTENDANCE_SELF_SERVICE_PERMISSIONS,
@@ -0,0 +1,162 @@
import { useEffect, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom";
import { useAuth } from "../../auth/AuthContext";
import { downloadLoanEvidence, fetchCompanyLoans, fetchLoanLenders, fetchLenderAcknowledgement,
fetchLenderAcknowledgements, saveLoanAction, type LoanLenderAcknowledgement } from "../../api/companyLoans";
import { formatDisplayDate, formatDisplayDateTime } from "../../lib/displayFormat";
import { resolveErrorMessage } from "../../lib/resolveErrorMessage";
import { downloadBlob } from "../../lib/download";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog";
const statusLabels = { pending: "待出借人确认", confirmed: "出借人已确认", returned: "已退回财务", invalid: "内容或来源已变化,需重新发起" };
export function LoanLenderAcknowledgements({ finance }: { finance: boolean }) {
const { me } = useAuth(); const cache = useQueryClient();
const [searchParams, setSearchParams] = useSearchParams();
const selectedId = searchParams.get("lenderAcknowledgementId") ?? "";
const [page, setPage] = useState(1); const [lenderPage, setLenderPage] = useState(1); const [loanPage, setLoanPage] = useState(1);
const [proposing, setProposing] = useState(false);
const mode = selectedId ? "details" : proposing ? "propose" : null;
const [lender, setLender] = useState<{ id: string; version: number } | null>(null);
const [selectedLoans, setSelectedLoans] = useState<Record<string, number>>({});
const [reason, setReason] = useState(""); const [checked, setChecked] = useState(false);
const [requestKey, setRequestKey] = useState(() => crypto.randomUUID());
const [downloadError, setDownloadError] = useState<unknown>(null);
const alive = useRef(true); const actor = useRef(me?.id); actor.current = me?.id;
const selection = useRef(selectedId); selection.current = selectedId;
const requests = useRef(new Set<AbortController>());
useEffect(() => { alive.current = true; const pending = requests.current;
return () => { alive.current = false; for (const controller of pending) controller.abort(); pending.clear(); };
}, [me?.id, selectedId]);
const records = useQuery({ queryKey: ["loan-lender-acknowledgements", me?.id, page],
queryFn: ({ signal }) => fetchLenderAcknowledgements(page, signal) });
const lenders = useQuery({ queryKey: ["loan-lenders", me?.id, "acknowledgement", lenderPage],
queryFn: ({ signal }) => fetchLoanLenders(lenderPage, signal), enabled: finance && mode === "propose",
refetchOnWindowFocus: false, refetchOnReconnect: false });
const loans = useQuery({ queryKey: ["company-loans", me?.id, "acknowledgement", lender?.id, loanPage],
queryFn: ({ signal }) => fetchCompanyLoans(loanPage, lender?.id, signal), enabled: finance && mode === "propose" && Boolean(lender),
refetchOnWindowFocus: false, refetchOnReconnect: false });
const detail = useQuery({ queryKey: ["loan-lender-acknowledgements", me?.id, "detail", selectedId],
queryFn: ({ signal }) => fetchLenderAcknowledgement(selectedId, signal), enabled: mode === "details" && Boolean(selectedId),
staleTime: 0, gcTime: 0, refetchOnWindowFocus: false, refetchOnReconnect: false });
const mutation = useMutation({ mutationFn: async (action: "propose" | "confirm" | "return") => {
const actorId = me?.id; const controller = new AbortController(); requests.current.add(controller);
try {
if (action === "propose") {
if (!lender) return;
await saveLoanAction("/loan-lender-acknowledgements", { lenderId: lender.id, expectedIdentityVersion: lender.version,
loans: Object.entries(selectedLoans).map(([id, version]) => ({ id, version })), requestKey, reason }, "POST", controller.signal);
} else {
if (!detail.data) return;
await saveLoanAction(`/loan-lender-acknowledgements/${detail.data.id}/decision`, {
action, reason, contentHash: detail.data.contentHash, originalChecked: checked,
}, "POST", controller.signal);
}
if (!alive.current || actor.current !== actorId || selection.current !== selectedId) return;
await Promise.all(["loan-lender-acknowledgements", "company-loans"].map((key) => cache.invalidateQueries({ queryKey: [key, actorId] })));
if (alive.current && actor.current === actorId && selection.current === selectedId) close();
} finally { requests.current.delete(controller); }
} });
const resetMutation = mutation.reset;
useEffect(() => {
resetMutation(); setDownloadError(null); setReason(""); setChecked(false);
if (selectedId) {
setProposing(false); setLender(null); setSelectedLoans({}); setLenderPage(1); setLoanPage(1); setRequestKey(crypto.randomUUID());
}
}, [selectedId, me?.id, resetMutation]);
function selectStatement(id: string) {
setSearchParams((old) => { const params = new URLSearchParams(old);
if (id) params.set("lenderAcknowledgementId", id); else params.delete("lenderAcknowledgementId"); return params; });
}
function close() { setProposing(false); selectStatement(""); }
function open(next: "propose" | "details", id = "") {
mutation.reset(); setDownloadError(null); setProposing(next === "propose"); selectStatement(id); setLender(null); setSelectedLoans({});
setReason(""); setChecked(false); setLenderPage(1); setLoanPage(1); setRequestKey(crypto.randomUUID());
}
async function original(record: LoanLenderAcknowledgement, loanId: string, evidenceId: string, name: string) {
const actorId = me?.id; const controller = new AbortController(); requests.current.add(controller); setDownloadError(null);
try {
const result = await downloadLoanEvidence(loanId, evidenceId, "核对出借人借款声明的原借入凭据", undefined, undefined, record.id, controller.signal);
if (alive.current && actor.current === actorId && selection.current === selectedId) downloadBlob(result.blob, result.filename ?? name);
} catch (error) { if (alive.current && actor.current === actorId && selection.current === selectedId && !controller.signal.aborted) setDownloadError(error); }
finally { requests.current.delete(controller); }
}
const error = mutation.error ?? downloadError ?? (mode === "propose" ? lenders.error ?? loans.error : mode === "details" ? detail.error : records.error);
return <section className="space-y-4" aria-label="出借人确认">
<h2 className="font-semibold">出借人确认借款</h2>
<p className="text-sm text-zinc-600">由出借人本人确认借出事实及截至日未收回本金,作为借款确认材料。公司代表授权及代账余额核对分别办理。</p>
{finance ? <Button onClick={() => open("propose")}>发起出借人确认</Button> : null}
{!mode && error ? <p role="alert">{resolveErrorMessage(error)}</p> : null}
{records.isPending ? <p role="status">加载出借人确认…</p> : null}
{records.isSuccess && !records.data.items.length ? <p>暂无可查看的出借人确认。</p> : null}
{records.data?.items.map((record) => <section key={record.id} className="card-std space-y-2 p-4">
<h3 className="break-words font-semibold">{record.companyName} · {record.lenderName} · {record.loans.length} 笔借款</h3>
<p>{statusLabels[record.status]}</p>
<p className="text-sm text-zinc-600">{record.proposedByName}发起 · {formatDisplayDateTime(record.proposedAt)}</p>
<Button variant="outline" onClick={() => open("details", record.id)}>{record.canDecide ? "核对并处理借款声明" : "查看借款确认记录"}</Button>
</section>)}
<div className="flex items-center gap-3"><Button variant="outline" disabled={page === 1} onClick={() => setPage(page - 1)}>上一页确认</Button>
<span>第 {page} 页</span><Button variant="outline" disabled={!records.data || page * 30 >= records.data.total} onClick={() => setPage(page + 1)}>下一页确认</Button></div>
<Dialog open={mode !== null} onOpenChange={(value) => { if (!value && !mutation.isPending) close(); }}>
<DialogContent className="max-h-[90dvh] overflow-y-auto"><DialogHeader>
<DialogTitle>{mode === "propose" ? "发起出借人确认" : "出借人借款声明"}</DialogTitle>
<DialogDescription>{mode === "propose" ? "选择同一出借人的借款,发送后固定本次明细和原件,交由出借人本人确认。" : "请以出借人身份核对借款和原件。确认后保留本次记录,内容变化需要重新确认。"}</DialogDescription>
</DialogHeader>
<form className="grid min-w-0 gap-4" onSubmit={(event) => { event.preventDefault(); mutation.mutate(mode === "propose" ? "propose" : "confirm"); }}>
{error ? <p role="alert" className="text-sm text-red-700">{resolveErrorMessage(error)}</p> : null}
{mode === "propose" ? <>
<label className="grid gap-2 text-sm">出借人<select aria-label="声明出借人" required className="min-h-11 min-w-0 w-full rounded border px-3" value={lender?.id ?? ""}
onChange={(event) => { const item = lenders.data?.items.find((l) => l.id === event.target.value);
setLender(item ? { id: item.id, version: item.identityVersion } : null); setSelectedLoans({}); setLoanPage(1); }}>
<option value="">请选择已关联员工的出借人</option>{lenders.data?.items.filter((l) => l.employeeId && l.employeeId !== me?.id && l.identityStatus === "verified")
.map((l) => <option key={l.id} value={l.id}>{l.name} · {l.legalEntityName}</option>)}</select></label>
<div className="flex gap-2"><Button type="button" variant="outline" disabled={lenderPage === 1} onClick={() => setLenderPage(lenderPage - 1)}>上一页出借人</Button>
<Button type="button" variant="outline" disabled={!lenders.data || lenderPage * 30 >= lenders.data.total} onClick={() => setLenderPage(lenderPage + 1)}>下一页出借人</Button></div>
<fieldset className="space-y-2"><legend className="text-sm">待确认或需重新确认的借款(已选 {Object.keys(selectedLoans).length} 笔,最多 50 笔)</legend>
{loans.isPending && lender ? <p role="status">加载借款…</p> : null}
{loans.data?.items.filter((l) => l.confirmationStatus !== "confirmed" || l.lenderAcknowledgementRenewable).map((loan) => {
const available = loan.evidences.some((e) => e.kind === "incoming" && !e.archivedAt && e.governanceStatus === "approved");
return <label key={loan.id} className="flex min-h-11 items-start gap-2 text-sm">
<input className="mt-1" type="checkbox" checked={loan.id in selectedLoans} disabled={!available || (!(loan.id in selectedLoans) && Object.keys(selectedLoans).length >= 50)}
onChange={(event) => setSelectedLoans((old) => { const next = { ...old }; if (event.target.checked) next[loan.id] = loan.version; else delete next[loan.id]; return next; })} />
<span className="break-words">{loan.loanNo} · {loan.originReference} · 原本金 ¥{loan.originalPrincipal} · 截至 {formatDisplayDate(loan.balanceAsOf)} 未还 ¥{loan.openingOutstandingPrincipal}{loan.confirmationStatus === "confirmed" ? "(余额已确认,本次仅重新确认声明)" : ""}{!available ? "(先上传原借入凭据)" : ""}</span>
</label>;
})}
</fieldset>
{lender ? <div className="flex gap-2"><Button type="button" variant="outline" disabled={loanPage === 1} onClick={() => setLoanPage(loanPage - 1)}>上一页借款</Button>
<Button type="button" variant="outline" disabled={!loans.data || loanPage * 30 >= loans.data.total} onClick={() => setLoanPage(loanPage + 1)}>下一页借款</Button></div> : null}
<label className="grid gap-2 text-sm">发起说明<Input required minLength={2} maxLength={500} value={reason} onChange={(e) => setReason(e.target.value)} /></label>
<Button type="submit" disabled={mutation.isPending || !lender || !loans.isSuccess || !Object.keys(selectedLoans).length || reason.trim().length < 2}>{mutation.isPending ? "发送中…" : "发送给出借人"}</Button>
</> : <>
{detail.isPending ? <p role="status">加载声明明细…</p> : null}
{detail.data ? <>
<p>{detail.data.companyName} · 出借人:{detail.data.lenderName}</p><p>{statusLabels[detail.data.status]}</p>
<p className="break-words text-sm">财务说明:{detail.data.reason}</p>
{detail.data.loans.map((loan) => <section key={loan.loanId} className="space-y-2 rounded border p-3 text-sm">
<p className="break-words">{loan.loanNo} · {loan.originReference}</p>
<p>借入日期:{formatDisplayDate(loan.receivedDate)} · 原始本金:¥{loan.originalPrincipal}</p>
<p>截至 {formatDisplayDate(loan.balanceAsOf)} 未还本金:¥{loan.openingOutstandingPrincipal}</p>
{loan.originals.map((e) => <div key={e.id} className="min-w-0">
<Button type="button" variant="link" className="max-w-full whitespace-normal break-all" disabled={!detail.data?.canReadOriginals}
onClick={() => original(detail.data!, loan.loanId, e.id, e.originalName)}>查看原借入凭据:{e.originalName}</Button>
</div>)}
</section>)}
{detail.data.decision ? <p className="break-words text-sm">{detail.data.decision.lenderName}于 {formatDisplayDateTime(detail.data.decision.createdAt)}
{detail.data.decision.action === "confirm" ? "确认" : "退回"}:{detail.data.decision.reason}</p> : null}
{detail.data.canDecide ? <>
<label className="flex min-h-11 items-start gap-2 text-sm"><input type="checkbox" className="mt-1" checked={checked} onChange={(e) => setChecked(e.target.checked)} />
<span>本人已核对原件,确认以上款项为本人借给所列公司的本金,且截至所列日期未收回本金与明细一致</span></label>
<label className="grid gap-2 text-sm">确认或退回说明<Input required minLength={2} maxLength={500} value={reason} onChange={(e) => setReason(e.target.value)} /></label>
<div className="flex flex-wrap gap-2"><Button type="submit" disabled={mutation.isPending || !checked || reason.trim().length < 2}>确认借款声明</Button>
<Button type="button" variant="outline" disabled={mutation.isPending || reason.trim().length < 2} onClick={() => mutation.mutate("return")}>退回财务</Button></div>
</> : null}
</> : null}
</>}
</form>
<DialogFooter><Button type="button" variant="outline" disabled={mutation.isPending} onClick={close}>关闭</Button></DialogFooter>
</DialogContent>
</Dialog>
</section>;
}
+8
查看文件
@@ -24,6 +24,10 @@ export function resolveNotificationTargetLink(item: NotificationTarget) {
return `/payment-batches?id=${encodeSegment(item.targetId)}`;
}
if (item.targetType === "loan_lender_acknowledgement") {
return `/company-loans?lenderAcknowledgementId=${encodeSegment(item.targetId)}`;
}
if (item.targetType === "leave_request") {
return `/leave-requests?highlight=${item.targetId}`;
}
@@ -70,6 +74,10 @@ export function resolveMobileNotificationTargetLink(item: NotificationTarget) {
return `/mobile/payment-batches?id=${encodeSegment(item.targetId)}`;
}
if (item.targetType === "loan_lender_acknowledgement") {
return `/mobile/company-loans?lenderAcknowledgementId=${encodeSegment(item.targetId)}`;
}
if (item.targetType === "leave_request") {
return `/mobile/leave/${encodeSegment(item.targetId)}`;
}
+23 -8
查看文件
@@ -1,6 +1,6 @@
import { cloneElement, isValidElement, useEffect, useId, useRef, useState, type FormEvent, type ReactNode } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { Link, useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { fetchLegalEntities } from "../api/masterData";
@@ -10,6 +10,7 @@ import { createLoanRepayment, downloadLoanEvidence, fetchCompanyLoans, fetchLoan
import { LoanRelatedAuthorizations } from "../components/common/LoanRelatedAuthorizations";
import { LoanEmployeeLenderDialog } from "../components/common/LoanEmployeeLenderDialog";
import { LoanIncomingReceiptDialog } from "../components/common/LoanIncomingReceiptDialog";
import { LoanLenderAcknowledgements } from "../components/common/LoanLenderAcknowledgements";
import { ListWorkspace, WorkspaceHeader, HeaderActionGroup } from "../components/patterns/ListWorkspace";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
@@ -32,12 +33,19 @@ function Field({ label, children }: { label: string; children: ReactNode }) {
export function CompanyLoansPage() {
const { me, permissions } = useAuth(); const navigate = useNavigate(); const cache = useQueryClient();
const [searchParams, setSearchParams] = useSearchParams();
const acknowledgementId = searchParams.get("lenderAcknowledgementId");
const paymentPath = useLocation().pathname.startsWith("/mobile/") ? "/mobile/payment-batches" : "/payment-batches";
const mounted = useRef(true);
useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
const allowed = Boolean(me?.roleCodes.some((role) => ["finance", "finance_mgr"].includes(role))) && hasPermission(permissions, "loan.manage");
const [page, setPage] = useState(1); const [lenderPage, setLenderPage] = useState(1); const [repayPage, setRepayPage] = useState(1);
const [tab, setTab] = useState<"loans" | "lenders" | "repayments" | "authorizations">("loans");
const [selectedTab, setSelectedTab] = useState<"loans" | "lenders" | "repayments" | "authorizations" | "acknowledgements">("loans");
const tab = acknowledgementId ? "acknowledgements" : selectedTab;
function setTab(next: typeof selectedTab) {
setSelectedTab(next);
if (acknowledgementId) setSearchParams((old) => { const params = new URLSearchParams(old); params.delete("lenderAcknowledgementId"); return params; });
}
const [mode, setMode] = useState<Mode>(null); const [selectedLender, setSelectedLender] = useState<LoanLender | null>(null);
const [selectedLoan, setSelectedLoan] = useState<CompanyLoan | null>(null);
const [evidence, setEvidence] = useState<{ loanId: string; id: string; name: string } | null>(null);
@@ -95,11 +103,15 @@ export function CompanyLoansPage() {
if (mounted.current && identity.current === actorId) setMode(null);
});
}
if (!allowed) return me?.roleCodes.some((r) => ["manager", "ceo"].includes(r)) && hasPermission(permissions, "payment.batch.review")
? <ListWorkspace><WorkspaceHeader title="公司借款" subtitle="查看分配给本人的公司代表授权及范围材料。" /><LoanRelatedAuthorizations key={me.id} finance={false} /></ListWorkspace>
const representsCompany = Boolean(me?.roleCodes.some((r) => ["manager", "ceo"].includes(r))) && hasPermission(permissions, "payment.batch.review");
if (!allowed) return representsCompany || hasPermission(permissions, "loan.acknowledge_own")
? <ListWorkspace><WorkspaceHeader title="公司借款" subtitle="查看本人的出借人确认及获分配的公司代表授权。" />
<LoanLenderAcknowledgements key={`ack-${me?.id}`} finance={false} />
{representsCompany ? <LoanRelatedAuthorizations key={`authority-${me?.id}`} finance={false} /> : null}</ListWorkspace>
: <p role="alert">当前账号无借款台账权限。</p>;
const error = mutation.error ?? loans.error ?? lenders.error ?? repayments.error ?? companies.error ?? banks.error ?? people.error;
const current = tab === "loans" ? loans : tab === "lenders" ? lenders : repayments;
const tableTab = tab === "loans" || tab === "lenders" || tab === "repayments";
const modalQueryBlocked = mode === "lender" ? !companies.isSuccess || !banks.isSuccess : mode === "account" ? !banks.isSuccess : mode === "identity" ? !people.isSuccess : mode === "loan" ? !lenders.isSuccess : mode === "repay" ? !lenders.isSuccess || !candidates.isSuccess : false;
const currentPage = tab === "loans" ? page : tab === "lenders" ? lenderPage : repayPage;
const setCurrentPage = tab === "loans" ? setPage : tab === "lenders" ? setLenderPage : setRepayPage;
@@ -109,13 +121,14 @@ export function CompanyLoansPage() {
<Button variant="outline" onClick={() => open("loanReceipt")}>上传回单登记借入</Button>
<Button onClick={() => open("loan")}>登记借入</Button><Button variant="outline" onClick={() => open("repay")}>申请还本</Button></HeaderActionGroup>} />
<div className="flex flex-wrap gap-2" role="tablist" aria-label="借款台账视图">
{([["loans", "借款台账"], ["lenders", "出借人"], ["repayments", "还款记录"], ["authorizations", "公司代表授权"]] as const).map(([key, label]) =>
{([["loans", "借款台账"], ["lenders", "出借人"], ["repayments", "还款记录"], ["acknowledgements", "出借人确认"], ["authorizations", "公司代表授权"]] as const).map(([key, label]) =>
<Button key={key} role="tab" aria-selected={tab === key} variant={tab === key ? "default" : "outline"} onClick={() => setTab(key)}>{label}</Button>)}
</div>
{tab === "authorizations" ? <LoanRelatedAuthorizations key={me?.id} finance /> : null}
{tab === "acknowledgements" ? <LoanLenderAcknowledgements key={me?.id} finance /> : null}
{error && !mode ? <p role="alert" className="text-sm text-red-700">{resolveErrorMessage(error)}</p> : null}
{tab !== "authorizations" && current.isPending ? <p role="status">加载中…</p> : null}
{tab !== "authorizations" && current.isSuccess && !current.data.items.length ? <p className="card-std p-4">暂无记录;不能据此判断银行实际借还款情况。</p> : null}
{tableTab && current.isPending ? <p role="status">加载中…</p> : null}
{tableTab && current.isSuccess && !current.data.items.length ? <p className="card-std p-4">暂无记录;不能据此判断银行实际借还款情况。</p> : null}
{tab === "loans" && loans.isSuccess ? loans.data.items.map((loan) => <section key={loan.id} className="card-std min-w-0 space-y-3 p-4">
<div className="flex flex-wrap justify-between gap-2"><h2 className="font-semibold break-all">{loan.lender.name} · {loan.loanNo}</h2>
<span>{loan.confirmationStatus === "confirmed" ? "余额已确认" : "余额待核实"}</span></div>
@@ -126,6 +139,8 @@ export function CompanyLoansPage() {
</div>
<p className="break-words text-sm">原借入凭据:{loan.originReference}</p>
<details><summary className="min-h-11 cursor-pointer py-3 text-sm font-medium">借款材料与余额确认</summary>
{loan.lenderAcknowledgement ? <p className="my-2 text-sm">已有有效的出借人在线确认,可作为借款确认材料;代账余额核对材料仍需上传。</p>
: <p className="my-2 text-sm">协议或借款确认书也可由有效的出借人在线确认替代,请在“出借人确认”中发起。</p>}
<div className="grid gap-3 md:grid-cols-2">{Object.entries(evidenceLabels).map(([kind, label]) => <div key={kind} className="rounded-md border p-3">
<Label htmlFor={`${loan.id}-${kind}`}>{label}</Label>
<Input id={`${loan.id}-${kind}`} type="file" className="mt-2" accept=".pdf,.png,.jpg,.jpeg,.webp" disabled={mutation.isPending}
@@ -153,7 +168,7 @@ export function CompanyLoansPage() {
<p className="text-sm">会计交接:{repayment.accountingHandoffRef ?? "待交接"}</p>
<Link className="inline-flex min-h-11 items-center text-primary underline" to={`${paymentPath}?id=${encodeURIComponent(repayment.paymentBatchId)}`}>查看还款分配、付款与回单</Link>
</section>) : null}
{tab !== "authorizations" ? <div className="flex items-center gap-3"><Button variant="outline" disabled={currentPage <= 1} onClick={() => setCurrentPage(currentPage - 1)}>上一页</Button>
{tableTab ? <div className="flex items-center gap-3"><Button variant="outline" disabled={currentPage <= 1} onClick={() => setCurrentPage(currentPage - 1)}>上一页</Button>
<span>第 {currentPage} 页</span><Button variant="outline" disabled={!current.data || currentPage * 30 >= current.data.total} onClick={() => setCurrentPage(currentPage + 1)}>下一页</Button></div> : null}
{mode === "employeeLender" || mode === "employeeRefresh" ? <LoanEmployeeLenderDialog key={`${me?.id}-${requestKey}`}
lender={mode === "employeeRefresh" ? selectedLender : null} onClose={() => setMode(null)} onExternal={() => open("lender")}
+1 -1
查看文件
@@ -104,7 +104,7 @@ export function MobileMePage() {
<SectionPanel title="移动入口" description="只保留手机上常用的个人办事入口。">
<div className="mobile-entry-grid mobile-me-entry-grid" data-testid="mobile-more-links">
{me?.roleCodes.some((role) => ["finance", "finance_mgr"].includes(role)) ? <MobileActionLink to="/mobile/company-loans" label="公司借款" icon="account_balance" /> : null}
{hasAnyPermission(permissions, ["loan.manage", "payment.batch.review"]) ? <MobileActionLink to="/mobile/company-loans" label="公司借款" icon="account_balance" /> : null}
<MobileActionLink to="/mobile/notifications" label="通知中心" icon="notifications" />
<MobileActionLink to="/mobile/leave" label="请假记录" icon="event_available" />
<MobileActionLink to="/mobile/reimbursements" label="报销" icon="request_quote" />
@@ -17,6 +17,7 @@ const PAGE_SIZE = 20;
const NOTIFICATION_TYPE_OPTIONS = [
{ label: "全部", value: "" },
{ label: "审批事件", value: "workflow.task_assigned,workflow.task_reminded,workflow.task_overdue" },
{ label: "借款确认", value: "loan.lender_acknowledgement_requested" },
{ label: "报销", value: "reimbursement.approved,reimbursement.rejected,reimbursement.returned,reimbursement.paid,payment_batch.review_requested,payment_batch.execution_requested" },
{ label: "请假", value: "leave.approved,leave.rejected" },
{ label: "采购", value: "purchase_request.approved,purchase_request.rejected" },
@@ -37,6 +38,7 @@ function formatTime(value: string) {
}
function notificationTypeLabel(type: string) {
if (type === "loan.lender_acknowledgement_requested") return "借款确认";
if (type.startsWith("workflow.")) return "审批事件";
if (type.startsWith("reimbursement.")) return "报销事件";
if (type.startsWith("payment_batch.")) return "付款事件";
+1
查看文件
@@ -15,6 +15,7 @@ const PAGE_SIZE = 20;
const NOTIFICATION_TYPE_OPTIONS = [
{ label: "全部", value: "" },
{ label: "审批提醒", value: "workflow.task_assigned" },
{ label: "借款确认", value: "loan.lender_acknowledgement_requested" },
{ label: "报销", value: "reimbursement.approved,reimbursement.rejected,reimbursement.returned,reimbursement.paid,payment_batch.review_requested,payment_batch.execution_requested" },
{ label: "请假", value: "leave.approved,leave.rejected" },
{ label: "采购", value: "purchase_request.approved,purchase_request.rejected" },
+2 -2
查看文件
@@ -304,8 +304,8 @@ const routeElements = (
}
/>
<Route path="company-loans" element={<RequirePermission anyOf={["loan.manage", "payment.batch.review"]}>{withRouteFallback(<CompanyLoansPage />)}</RequirePermission>} />
<Route path="mobile/company-loans" element={<RequirePermission anyOf={["loan.manage", "payment.batch.review"]}>{withRouteFallback(<CompanyLoansPage />)}</RequirePermission>} />
<Route path="company-loans" element={<RequirePermission anyOf={["loan.manage", "payment.batch.review", "loan.acknowledge_own"]}>{withRouteFallback(<CompanyLoansPage />)}</RequirePermission>} />
<Route path="mobile/company-loans" element={<RequirePermission anyOf={["loan.manage", "payment.batch.review", "loan.acknowledge_own"]}>{withRouteFallback(<CompanyLoansPage />)}</RequirePermission>} />
<Route
path="payment-batches"