追回证据刷新期间禁用选择并修复员工资料登录误报(09-17-company-loan-principal-repayment)

- 上传银行证据后、刷新完成前,证据下拉框与确认按钮禁用并提示,避免空证据提交
- 员工来源版本去掉 updated_at(登录会刷新),保留全部真实资料字段的拦截
- 补登录误报回归、字段变更拦截、两项变异及刷新挂起的浏览器检查
- 任务记录 E 节与 AC52–55,独立只读复核无阻断项

Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
2026-10-11 09:22:34 +08:00
共同撰写人 Claude Opus 5.5
父节点 91fe31f0db
当前提交 3c5cc544f5
共修改 7 个文件,包含 104 行新增和 32 行删除
@@ -24,6 +24,9 @@ export function LoanBankReconciliation({ repayment: r, attachments }: { repaymen
const [details, setDetails] = useState<Awaited<ReturnType<typeof readLoanBankResultDetails>> | null>(null);
const [action, setAction] = useState<string | null>(null); const [target, setTarget] = useState("");
const [pendingIds, setPendingIds] = useState<string[]>([]);
// The upload dialog closes before the batch detail refetch lands; until then a
// just-uploaded document is missing from the evidence lists.
const [refreshingEvidence, setRefreshingEvidence] = useState(false);
const [values, setValues] = useState<Record<string, string>>({}); const [file, setFile] = useState<File | null>(null);
const value = (key: string) => values[key] ?? "";
const set = (key: string, next: string) => setValues((old) => ({ ...old, [key]: next }));
@@ -59,12 +62,16 @@ export function LoanBankReconciliation({ repayment: r, attachments }: { repaymen
if (action === "event" || action === "refund") await saveLoanAction(`${base}/loan-bank-results`, { kind: action === "refund" ? "return" : "late_success", ...(action === "refund" ? { parentId: target } : {}), evidenceId: value("evidence"), requestKey: crypto.randomUUID(), attributionNote: note, exclusionNote: value("exclusion"), fields: [{ Name: "付款账号", Value: value("payer") }, { Name: "收款账号", Value: value("payee") }, { Name: "金额", Value: value("amount") }, { Name: "币种", Value: "CNY" }, { Name: "交易状态", Value: "交易成功" }, { Name: "流水号", Value: value("reference") }] });
if (action === "reviewEvent") await saveLoanAction(`${base}/loan-bank-results/${target}/review`, { ...reviewBody, ...(decision === "reject" && value("evidence") ? { correctionEvidenceId: value("evidence"), correctionKind: value("correction") || "misassociated" } : {}) });
if (action === "apply") await saveLoanAction(`${base}/loan-bank-results/${target}/apply`, {});
}, onSuccess: async () => { if (action === "details") return; setAction(null); await Promise.all(["payment-batches", "company-loans", "loan-repayments"].map((key) => cache.invalidateQueries({ queryKey: [key] }))); } });
}, onSuccess: async () => {
if (action === "details") return; const uploaded = action === "upload"; setAction(null); if (uploaded) setRefreshingEvidence(true);
try { await Promise.all(["payment-batches", "company-loans", "loan-repayments"].map((key) => cache.invalidateQueries({ queryKey: [key] }))); }
finally { if (uploaded) setRefreshingEvidence(false); }
} });
function open(name: string, id = "") { setAction(name); setTarget(id); setValues({}); setPendingIds([]); setDetails(null); setFile(null); mutation.reset(); }
const button = (name: string, id = "") => <Button key={`${name}:${id}`} type="button" variant="outline" onClick={() => open(name, id)}>{actionLabel(name)}</Button>;
const input = (key: string, label: string, type = "text") => <label className="grid gap-2 text-sm" key={key}>{label}<Input required type={type} value={value(key)} onChange={(e) => set(key, e.target.value)} /></label>;
const select = (key: string, label: string, choices: Array<[string, string]>, required = true) => <label className="grid gap-2 text-sm" key={key}>{label}<select aria-label={label} required={required} className="min-h-11 min-w-0 w-full rounded border px-3" value={value(key)} onChange={(e) => set(key, e.target.value)}><option value="">请选择{required ? "" : "(可不选)"}</option>{choices.map(([id, title]) => <option key={id} value={id}>{title}</option>)}</select></label>;
const evidenceSelect = (key: string, label: string, kind: string, required = true) => select(key, label, evidences.filter((e) => !e.voided && e.evidenceKind === kind).map((e) => [e.id, e.originalName]), required);
const select = (key: string, label: string, choices: Array<[string, string]>, required = true, refreshing = false) => <label className="grid gap-2 text-sm" key={key}>{label}<select aria-label={label} required={required} disabled={refreshing} aria-busy={refreshing || undefined} className="min-h-11 min-w-0 w-full rounded border px-3 disabled:opacity-60" value={value(key)} onChange={(e) => set(key, e.target.value)}><option value="">{refreshing ? "正在刷新证据列表…" : `请选择${required ? "" : "(可不选)"}`}</option>{choices.map(([id, title]) => <option key={id} value={id}>{title}</option>)}</select>{refreshing ? <span role="status" className="text-sm text-amber-800">正在刷新证据列表,刚上传的证据稍后可选</span> : null}</label>;
const evidenceSelect = (key: string, label: string, kind: string, required = true) => select(key, label, evidences.filter((e) => !e.voided && e.evidenceKind === kind).map((e) => [e.id, e.originalName]), required, refreshingEvidence);
const exportLabel = (id: string | null) => id ? `第 ${exports.find((e) => e.id === id)?.sequence ?? "未知"} 次导出` : "系统外手工提交";
const submissionLabel = (id: string) => submissions.find((s) => s.id === id)?.bankInstructionRef ?? "提交记录已不可用";
const evidenceLink = (id: string) => <Button type="button" variant="outline" onClick={() => open("download", id)}>证据 {evidences.findIndex((e) => e.id === id) + 1}:{evidences.find((e) => e.id === id)?.originalName ?? "不可用"}</Button>;
@@ -105,7 +112,7 @@ export function LoanBankReconciliation({ repayment: r, attachments }: { repaymen
{action === "reviewEvent" && value("decision") === "reject" ? <>{evidenceSelect("evidence", "误关联或重复登记的更正证据(晚到成功必填)", "bank_terminal", false)}{select("correction", "关闭原因", [["misassociated", "误关联"], ["duplicate", "重复登记"]], false)}</> : null}
{action === "event" || action === "refund" ? <>{evidenceSelect("evidence", "对应的成功银行回单", action === "refund" ? "incoming_return" : "late_success")}{input("payer", "回单付款账号")}{input("payee", "回单收款账号")}{input("amount", "回单人民币金额")}{input("reference", "回单流水号")}{input("exclusion", "为什么不属于其他候选批次或交易")}</> : null}
{!['upload', 'submitRecovery', 'confirm', 'apply'].includes(action ?? '') ? <label className="grid gap-2 text-sm">依据与说明<textarea required minLength={10} maxLength={500} className="min-h-24 rounded border px-3 py-2" value={value("note")} onChange={(e) => set("note", e.target.value)} /></label> : null}
<Button type="submit" disabled={mutation.isPending}>{mutation.isPending ? "处理中…" : "确认"}</Button>
<Button type="submit" disabled={mutation.isPending || refreshingEvidence}>{mutation.isPending ? "处理中…" : "确认"}</Button>
</form>
</DialogContent></Dialog>
</section>;