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 { useAuth } from "../auth/AuthContext"; import { hasPermission } from "../auth/permissions"; import { fetchLegalEntities } from "../api/masterData"; import { fetchBankInstitutions } from "../api/paymentAccounts"; import { createLoanRepayment, downloadLoanEvidence, fetchCompanyLoans, fetchLoanIdentities, fetchLoanLenders, fetchLoanRepayments, saveLoanAction, uploadLoanEvidence, type CompanyLoan, type LoanLender } from "../api/companyLoans"; import { ListWorkspace, WorkspaceHeader, HeaderActionGroup } from "../components/patterns/ListWorkspace"; import { Button } from "../components/ui/button"; import { Input } from "../components/ui/input"; import { Label } from "../components/ui/label"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../components/ui/dialog"; import { resolveErrorMessage } from "../lib/resolveErrorMessage"; import { formatDisplayDate } from "../lib/displayFormat"; import { downloadBlob } from "../lib/download"; const evidenceLabels: Record = { agreement: "协议或借款确认书", incoming: "原借入凭据", accounting_confirmation: "余额核对材料", adjustment: "系统外已还本金依据" }; const stateLabels: Record = { draft: "待付款复核", reviewed: "待付款", executed: "已付款", cancelled: "已取消", failed: "登记未完成" }; type Mode = "lender" | "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"; function Field({ label, children }: { label: string; children: ReactNode }) { const id = useId(); return
{isValidElement<{ id?: string }>(children) ? cloneElement(children, { id }) : children}
; } export function CompanyLoansPage() { const { me, permissions } = useAuth(); const navigate = useNavigate(); const cache = useQueryClient(); 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">("loans"); const [mode, setMode] = useState(null); const [selectedLender, setSelectedLender] = useState(null); const [selectedLoan, setSelectedLoan] = useState(null); const [evidence, setEvidence] = useState<{ loanId: string; id: string; name: string } | null>(null); const [lenderId, setLenderId] = useState(""); const [identitySearch, setIdentitySearch] = useState(""); const [amounts, setAmounts] = useState>({}); const [candidatePage, setCandidatePage] = useState(1); const [requestKey, setRequestKey] = useState(() => crypto.randomUUID()); const identity = useRef(me?.id); identity.current = me?.id; const loans = useQuery({ queryKey: ["company-loans", me?.id, page], queryFn: ({ signal }) => fetchCompanyLoans(page, undefined, signal), enabled: allowed }); const lenders = useQuery({ queryKey: ["loan-lenders", me?.id, lenderPage], queryFn: ({ signal }) => fetchLoanLenders(lenderPage, signal), enabled: allowed }); const repayments = useQuery({ queryKey: ["loan-repayments", me?.id, repayPage], queryFn: ({ signal }) => fetchLoanRepayments(repayPage, signal), enabled: allowed }); const candidates = useQuery({ queryKey: ["company-loans", me?.id, "candidates", lenderId, candidatePage], queryFn: ({ signal }) => fetchCompanyLoans(candidatePage, lenderId, signal), enabled: allowed && mode === "repay" && Boolean(lenderId) }); const companies = useQuery({ queryKey: ["loan-companies", me?.id], queryFn: () => fetchLegalEntities({ enabled: true }), enabled: allowed && mode === "lender" }); const banks = useQuery({ queryKey: ["loan-banks", me?.id], queryFn: fetchBankInstitutions, enabled: allowed && ["lender", "account"].includes(mode ?? "") }); const people = useQuery({ queryKey: ["loan-identities", me?.id, identitySearch], queryFn: ({ signal }) => fetchLoanIdentities(identitySearch, signal), enabled: allowed && mode === "identity" }); const mutation = useMutation({ mutationFn: async (action: () => Promise) => action(), onSuccess: async () => { await Promise.all(["company-loans", "loan-lenders", "loan-repayments", "payment-batches"].map((key) => cache.invalidateQueries({ queryKey: [key] }))); } }); useEffect(() => { setMode(null); setSelectedLender(null); setSelectedLoan(null); setAmounts({}); }, [me?.id]); function open(next: Mode, lender: LoanLender | null = null, loan: CompanyLoan | null = null) { mutation.reset(); setRequestKey(crypto.randomUUID()); setMode(next); setSelectedLender(lender); setSelectedLoan(loan); setLenderId(lender?.id ?? ""); setAmounts({}); setIdentitySearch(""); setCandidatePage(1); } async function submit(event: FormEvent) { event.preventDefault(); const values = new FormData(event.currentTarget); const text = (key: string) => String(values.get(key) ?? "").trim(); const actorId = me?.id; mutation.mutate(async () => { if (mode === "lender") await saveLoanAction("/loan-lenders", { requestKey, legalEntityId: text("company"), name: text("name"), bankInstitutionId: text("bank"), bankName: text("bankName"), bankAccount: text("account"), accountName: text("accountName") }); if (mode === "account" && selectedLender) await saveLoanAction(`/loan-lenders/${selectedLender.id}/account`, { expectedVersion: selectedLender.accountVersion, bankInstitutionId: text("bank"), bankName: text("bankName"), bankAccount: text("account"), accountName: text("accountName"), }, "PATCH"); if (mode === "identity" && selectedLender) await saveLoanAction(`/loan-lenders/${selectedLender.id}/identity-confirmation`, { expectedVersion: selectedLender.identityVersion, employeeId: text("employee") === "external" ? null : text("employee"), externalPersonConfirmed: text("employee") === "external", reason: text("reason"), }); if (mode === "loan") await saveLoanAction("/company-loans", { requestKey, lenderId: text("lender"), originReference: text("origin"), originalPrincipal: text("principal"), openingOutstandingPrincipal: text("opening"), receivedDate: text("received"), balanceAsOf: text("asOf"), historicalEntry: text("entry") === "historical" }); if (mode === "confirm" && selectedLoan) await saveLoanAction(`/company-loans/${selectedLoan.id}/confirm`, { expectedVersion: selectedLoan.version, accountingConfirmationRef: text("reference"), }); if (mode === "download" && evidence) { const response = await downloadLoanEvidence(evidence.loanId, evidence.id, text("reason")); if (mounted.current && identity.current === actorId) downloadBlob(response.blob, response.filename ?? evidence.name); } if (mode === "repay") { const response = await createLoanRepayment({ requestKey, lenderId, reason: text("reason"), allocations: Object.entries(amounts).filter(([, amount]) => amount.trim()).map(([loanId, amount]) => ({ loanId, amount: amount.trim() })) }); if (mounted.current && identity.current === actorId) navigate(`${paymentPath}?id=${encodeURIComponent(response.item.id)}`); } if (mounted.current && identity.current === actorId) setMode(null); }); } if (!allowed) return

当前账号无借款台账权限。

; 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 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; return } />
{([["loans", "借款台账"], ["lenders", "出借人"], ["repayments", "还款记录"]] as const).map(([key, label]) => )}
{error && !mode ?

{resolveErrorMessage(error)}

: null} {current.isPending ?

加载中…

: null} {current.isSuccess && !current.data.items.length ?

暂无记录;不能据此判断银行实际借还款情况。

: null} {tab === "loans" && loans.isSuccess ? loans.data.items.map((loan) =>

{loan.lender.name} · {loan.loanNo}

{loan.confirmationStatus === "confirmed" ? "余额已确认" : "余额待核实"}

{loan.lender.legalEntityName} · 借入 {formatDisplayDate(loan.receivedDate)} · 核对截至 {formatDisplayDate(loan.balanceAsOf)} · {loan.historicalEntry ? "历史登记" : "当期借入"}

{[["原本金", loan.originalPrincipal], ["未还本金", loan.outstanding], ["待付款占用", loan.reserved], ["可申请还本", loan.available]].map(([label, value]) =>
{label}
¥{value}
)}

原借入凭据:{loan.originReference}

借款材料与余额确认
{Object.entries(evidenceLabels).map(([kind, label]) =>
{ const file = event.currentTarget.files?.[0]; event.currentTarget.value = ""; if (file) mutation.mutate(() => uploadLoanEvidence(loan.id, kind, file)); }} /> {loan.evidences.filter((e) => e.kind === kind && !e.archivedAt).map((e) => )}
)}
{loan.confirmationStatus !== "confirmed" ? : null}
) : null} {tab === "lenders" && lenders.isSuccess ? lenders.data.items.map((lender) =>

{lender.name} · {lender.legalEntityName}

{lender.bankName} · {lender.bankAccountMasked ?? "账户不可用"} · {lender.identityStatus === "verified" ? "身份已核实" : "身份待核实"}

) : null} {tab === "repayments" && repayments.isSuccess ? repayments.data.items.map((repayment) =>

{repayment.lenderName} · {repayment.repaymentNo}

¥{repayment.amount} · {stateLabels[repayment.status] ?? "待核对"}

{repayment.bankResultState === "unknown" ?

银行结果待查证,额度保持占用,请勿重复付款。

: null}

会计交接:{repayment.accountingHandoffRef ?? "待交接"}

查看还款分配、付款与回单
) : null}
第 {currentPage} 页
{ if (!opened && !mutation.isPending) setMode(null); }}> {({ lender: "登记出借人", account: "更新收款资料", identity: "核实身份关联", loan: "登记借入", confirm: "确认期初余额", repay: "申请归还本金", download: "下载借款材料" } as Record)[mode ?? ""]} {mode === "repay" ? "同一公司和出借人可合并多笔借款,提交后进入独立付款复核。" : mode === "confirm" ? "请根据凭据与代账核对余额。确认后不能覆盖修改,系统外已还本金须有依据。" : mode === "account" ? "更新后须重新确认身份,原未付申请失效,请安全取消后重新申请。" : "仅录入已核对的业务事实;敏感材料仅供有权人员查阅。"}
{error ?

{resolveErrorMessage(error)}

: null} {mode === "lender" ? <> : null} {mode === "lender" || mode === "account" ? <> : null} {mode === "identity" ? <> setIdentitySearch(e.target.value)} placeholder="姓名或员工编号" /> : null} {mode === "loan" ? <> : null} {mode === "confirm" ? <>

原本金 ¥{selectedLoan?.originalPrincipal},核对日未还 ¥{selectedLoan?.openingOutstandingPrincipal}

: null} {mode === "repay" ? <>

填写已确认借款的分配金额,留空表示本次不归还。翻页保留已填写金额。

{candidates.error ?

{resolveErrorMessage(candidates.error)}

: null} {candidates.isSuccess ? candidates.data.items.filter((l) => l.lender.id === lenderId && l.confirmationStatus === "confirmed").map((l) => setAmounts({ ...amounts, [l.id]: e.target.value })} />) : null} : null} {mode === "repay" && lenderId ?
第 {candidatePage} 页 · 已填 {Object.values(amounts).filter((v) => v.trim()).length} 笔
: null} {["identity", "repay", "download"].includes(mode ?? "") ? : null}
; }