借款出借人线上确认借款并以站内通知直达(09-17-company-loan-principal-repayment)
- 新增出借人确认声明:财务发起,出借人本人查看原件后确认或退回;快照与决定不可改删,金额、日期、原件或身份变化后整组失效 - 余额确认接受纸质协议或有效线上声明二选一,代账余额核对材料仍必需;声明与公司代表授权分离 - 普通员工无常驻入口,财务发起后仅通知对应出借人并直达确认页 - 新增迁移 20261010010000_loan_lender_acknowledgements(仅新增三表) - 测试:单测、隔离 HTTP/浏览器、完整借款回归;独立只读复核无阻断;同步发布记录与操作手册 Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
@@ -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 = {
|
||||
|
||||
@@ -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": "分配金额超过可还本金,或借款不属于同一公司和出借人,请重新核对。",
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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 "付款事件";
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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"
|
||||
|
||||
在新工单中引用
屏蔽一个用户