借款材料区按需显示并前置缺项提示(09-17-company-loan-principal-repayment)
ci-e2e / e2e (push) Waiting to run
ci-minimal / check-minimal (push) Waiting to run

- 材料区只为仍缺的材料提供上传:原件已存显示可查看,在线确认后收起协议框,历史还款依据仅在登记期初小于原本金时出现
- 余额材料改中性文案(公司银行流水或代账明细账),确认字段改为“余额依据说明”,按钮改为“确认借款余额”并前置列出缺项
- 确认弹窗跟随最新列表状态:刷新中、刷新失败、版本变化、已确认或缺项时禁用按钮与提交
- 浏览器测试覆盖显隐、缺项、材料失效、在线确认失效、刷新与弹窗打开后状态变化;同步操作手册
- 只读复核 P2 已修复关闭;不改后端、接口与数据库

Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
2026-10-12 15:58:10 +08:00
共同撰写人 Claude Opus 5.5
父节点 3a4a5969bb
当前提交 c251729267
共修改 8 个文件,包含 274 行新增和 35 行删除
+64 -13
查看文件
@@ -22,6 +22,20 @@ import { downloadBlob } from "../lib/download";
const evidenceLabels: Record<string, string> = { agreement: "协议或借款确认书", incoming: "原借入凭据",
accounting_confirmation: "余额核对材料", adjustment: "系统外已还本金依据" };
const usableEvidence = (loan: CompanyLoan, kind: string) =>
loan.evidences.some((e) => e.kind === kind && !e.archivedAt && e.governanceStatus === "approved");
// Amounts arrive as fixed two-decimal strings; compare in cents. Uses the
// registered opening balance, not `outstanding`, which falls with OA repayments.
const cents = (amount: string) => BigInt(amount.replace(".", ""));
const needsAdjustment = (loan: CompanyLoan) => cents(loan.openingOutstandingPrincipal) < cents(loan.originalPrincipal);
// Mirrors the server's evidence rule for balance confirmation so the card can
// name what is missing up front; the server still decides on submit.
function missingEvidence(loan: CompanyLoan) {
const required = ["incoming", "accounting_confirmation"];
if (!loan.lenderAcknowledgement) required.push("agreement");
if (needsAdjustment(loan)) required.push("adjustment");
return required.filter((kind) => !usableEvidence(loan, kind));
}
const stateLabels: Record<string, string> = { draft: "待付款复核", reviewed: "待付款", executed: "已付款", cancelled: "已取消", failed: "登记未完成" };
type Mode = "lender" | "employeeLender" | "employeeRefresh" | "loanReceipt" | "identity" | "account" | "loan" | "confirm" | "repay" | "download" | null;
const selectClass = "min-h-11 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm";
@@ -70,8 +84,20 @@ export function CompanyLoansPage() {
mutation.reset(); setRequestKey(crypto.randomUUID()); setMode(next); setSelectedLender(lender); setSelectedLoan(loan);
setLenderId(lender?.id ?? ""); setAmounts({}); setIdentitySearch(""); setCandidatePage(1);
}
// The confirm dialog follows the live list, not the snapshot taken when it
// opened: a refresh, a failed refresh, a changed loan or a newly missing
// material blocks saving until the finance user reopens and rechecks.
const liveLoan = mode === "confirm" && selectedLoan ? loans.data?.items.find((l) => l.id === selectedLoan.id) : undefined;
const liveMissing = liveLoan ? missingEvidence(liveLoan) : [];
const confirmBlocker = mode !== "confirm" ? null
: loans.isFetching ? "借款列表正在刷新,请稍候。"
: loans.isError || !liveLoan ? "借款列表未能刷新,请关闭后重新打开。"
: liveLoan.version !== selectedLoan?.version || liveLoan.confirmationStatus === "confirmed" ? "借款信息已变化,请关闭后重新核对。"
: liveMissing.length ? `还缺:${liveMissing.map((kind) => evidenceLabels[kind]).join("、")}` : null;
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); const values = new FormData(event.currentTarget);
event.preventDefault();
if (confirmBlocker) return;
const values = new FormData(event.currentTarget);
const text = (key: string) => String(values.get(key) ?? "").trim();
const actorId = me?.id;
mutation.mutate(async () => {
@@ -113,6 +139,22 @@ export function CompanyLoansPage() {
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;
// Only the material a loan still needs offers an upload; satisfied or
// inapplicable kinds collapse to a status line, keeping existing files viewable.
function evidenceSlots(loan: CompanyLoan) {
const slots: Array<{ kind: string; label: string; note?: string; upload: boolean; action?: ReactNode }> = [];
const hasIncoming = usableEvidence(loan, "incoming");
slots.push({ kind: "incoming", label: evidenceLabels.incoming, note: hasIncoming ? "已保存,可查看" : undefined, upload: !hasIncoming });
slots.push(loan.lenderAcknowledgement
? { kind: "agreement", label: "借款确认", note: "出借人已在线确认", upload: false }
: { kind: "agreement", label: evidenceLabels.agreement, note: "有效在线确认或协议/借款确认书,任选一种", upload: true,
action: loan.lender.employeeId ? <Button type="button" variant="link" className="px-0" onClick={() => setTab("acknowledgements")}>去出借人确认查看或办理</Button> : undefined });
slots.push({ kind: "accounting_confirmation", label: "上传余额核对材料", note: "公司银行流水或代账明细账,覆盖从借入到截至日", upload: true });
if (needsAdjustment(loan) || loan.evidences.some((e) => e.kind === "adjustment" && !e.archivedAt)) {
slots.push({ kind: "adjustment", label: evidenceLabels.adjustment, note: "登记的未还本金少于原本金,请补充此前还款的依据", upload: needsAdjustment(loan) });
}
return slots;
}
const currentPage = tab === "loans" ? page : tab === "lenders" ? lenderPage : repayPage;
const setCurrentPage = tab === "loans" ? setPage : tab === "lenders" ? setLenderPage : setRepayPage;
return <ListWorkspace>
@@ -139,17 +181,24 @@ 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}
<div className="grid gap-3 md:grid-cols-2">{evidenceSlots(loan).map(({ kind, label, note, upload, action }) => <div key={kind} className="rounded-md border p-3">
{upload ? <Label htmlFor={`${loan.id}-${kind}`}>{label}</Label> : <p className="text-sm font-medium">{label}</p>}
{note ? <p className="mt-1 text-sm text-zinc-600">{note}</p> : null}
{upload ? <Input id={`${loan.id}-${kind}`} type="file" className="mt-2" accept=".pdf,.png,.jpg,.jpeg,.webp" disabled={mutation.isPending}
onChange={(event) => { const file = event.currentTarget.files?.[0]; event.currentTarget.value = "";
if (file) mutation.mutate(() => uploadLoanEvidence(loan.id, kind, file)); }} />
if (file) mutation.mutate(() => uploadLoanEvidence(loan.id, kind, file)); }} /> : null}
{action}
{loan.evidences.filter((e) => e.kind === kind && !e.archivedAt).map((e) => <Button key={e.id} variant="link" className="max-w-full whitespace-normal break-all"
onClick={() => { setEvidence({ loanId: loan.id, id: e.id, name: e.originalName }); open("download"); }}>{e.originalName}</Button>)}
onClick={() => { setEvidence({ loanId: loan.id, id: e.id, name: e.originalName }); open("download"); }}>{e.originalName}{e.governanceStatus === "approved" ? "" : "(未通过检查,不计入)"}</Button>)}
</div>)}</div>
{loan.confirmationStatus !== "confirmed" ? <Button className="mt-3" onClick={() => open("confirm", loan.lender, loan)}>确认期初余额</Button> : null}
{loan.confirmationStatus !== "confirmed" ? (() => {
const missing = missingEvidence(loan);
return <div className="mt-3 space-y-2">
{missing.length ? <p id={`${loan.id}-missing`} className="text-sm text-amber-800">还缺:{missing.map((kind) => evidenceLabels[kind]).join("、")}</p> : null}
<Button disabled={missing.length > 0 || loans.isFetching || loans.isError || mutation.isPending} aria-describedby={missing.length ? `${loan.id}-missing` : undefined}
onClick={() => open("confirm", loan.lender, loan)}>确认借款余额</Button></div>;
})() : loan.lender.employeeId && loan.lender.identityStatus === "verified"
? <Button variant="link" className="mt-2 px-0" onClick={() => setTab("authorizations")}>查看或办理公司代表授权</Button> : null}
</details>
</section>) : null}
{tab === "lenders" && lenders.isSuccess ? lenders.data.items.map((lender) => <section key={lender.id} className="card-std space-y-3 p-4">
@@ -177,9 +226,9 @@ export function CompanyLoansPage() {
onClose={() => setMode(null)} onManual={() => open("loan")} onSaved={() => { setMode(null); setTab("loans"); setPage(1); }} /> : null}
<Dialog open={mode !== null && mode !== "employeeLender" && mode !== "employeeRefresh" && mode !== "loanReceipt"} onOpenChange={(opened) => { if (!opened && !mutation.isPending) setMode(null); }}>
<DialogContent className="max-h-[90dvh] overflow-y-auto"><DialogHeader><DialogTitle>
{({ lender: "登记非员工出借人", account: "更新收款资料", identity: "核实身份关联", loan: "登记借入", confirm: "确认期初余额", repay: "申请归还本金", download: "下载借款材料" } as Record<string, string>)[mode ?? ""]}
{({ lender: "登记非员工出借人", account: "更新收款资料", identity: "核实身份关联", loan: "登记借入", confirm: "确认借款余额", repay: "申请归还本金", download: "下载借款材料" } as Record<string, string>)[mode ?? ""]}
</DialogTitle><DialogDescription>{mode === "repay" ? "同一公司和出借人可合并多笔借款。系统按已确认的授权范围确定独立复核或公司代表关联方确认,提交后不能切换模式。" :
mode === "confirm" ? "请根据凭据与代账核对余额。确认后不能覆盖修改,系统外已还本金须有依据。" :
mode === "confirm" ? "请根据凭据与公司银行流水或代账明细核对余额。确认后不能覆盖修改,系统外已还本金须有依据。" :
mode === "account" ? "更新后须重新核实身份关联。该出借人有还款正在办理时不能更新,须待处理完成或安全取消后再操作。" : "仅录入已核对的业务事实;敏感材料仅供有权人员查阅。"}</DialogDescription></DialogHeader>
<form onSubmit={submit} className="grid gap-4">
{error ? <p role="alert" className="text-sm text-red-700">{resolveErrorMessage(error)}</p> : null}
@@ -200,7 +249,9 @@ export function CompanyLoansPage() {
<Field label="实际到账日期"><Input name="received" required type="date" /></Field><Field label="余额核对截止日"><Input name="asOf" required type="date" /></Field>
<Field label="登记性质"><select name="entry" className={selectClass}><option value="historical">历史借入登记</option><option value="current">当期实际借入</option></select></Field></> : null}
{mode === "confirm" ? <><p>原本金 ¥{selectedLoan?.originalPrincipal},核对日未还 ¥{selectedLoan?.openingOutstandingPrincipal}</p>
<Field label="代账余额确认引用"><Input required name="reference" minLength={2} maxLength={200} /></Field></> : null}
{confirmBlocker ? <p id="loan-confirm-blocker" role="status" className="text-sm text-amber-800">{confirmBlocker}</p> : null}
<Field label="余额依据说明"><Input required name="reference" minLength={2} maxLength={200} aria-describedby="loan-confirm-reference-example" /></Field>
<p id="loan-confirm-reference-example" className="text-sm text-zinc-600">示例:公司银行流水,借入日至核对截止日,已逐笔核对借入及还款。</p></> : null}
{mode === "repay" ? <><Field label="出借人"><select required className={selectClass} value={lenderId} onChange={(e) => { setLenderId(e.target.value); setAmounts({}); setCandidatePage(1); }}><option value="">请选择</option>
{lenders.data?.items.map((l) => <option key={l.id} value={l.id}>{l.name} · {l.legalEntityName}</option>)}</select></Field>
<p className="text-sm text-zinc-600">填写已确认借款的分配金额,留空表示本次不归还。翻页保留已填写金额。</p>
@@ -210,7 +261,7 @@ export function CompanyLoansPage() {
{mode === "repay" && lenderId ? <div className="flex flex-wrap items-center gap-2"><Button type="button" variant="outline" disabled={candidatePage === 1} onClick={() => setCandidatePage(candidatePage - 1)}>上一页借款</Button>
<span>第 {candidatePage} 页 · 已填 {Object.values(amounts).filter((v) => v.trim()).length} 笔</span><Button type="button" variant="outline" disabled={!candidates.data || candidatePage * 30 >= candidates.data.total} onClick={() => setCandidatePage(candidatePage + 1)}>下一页借款</Button></div> : null}
{["identity", "repay", "download"].includes(mode ?? "") ? <Field label={mode === "download" ? "下载用途" : "说明"}><Input name="reason" required minLength={2} maxLength={500} /></Field> : null}
<Button type="submit" disabled={mutation.isPending || modalQueryBlocked || mode === "repay" && !Object.values(amounts).some((v) => v.trim())}>{mutation.isPending ? "处理中…" : mode === "repay" ? "提交还款申请" : "确认"}</Button>
<Button type="submit" disabled={mutation.isPending || modalQueryBlocked || Boolean(confirmBlocker) || mode === "repay" && !Object.values(amounts).some((v) => v.trim())}>{mutation.isPending ? "处理中…" : mode === "repay" ? "提交还款申请" : "确认"}</Button>
</form>
</DialogContent>
</Dialog>