Files
Public/apps/web/src/pages/LoanDetailPage.tsx
T
daiyongkang 76f266645d Initial commit: 风影 OA 全栈源码(API/Web/Flutter/IM)
含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。
排除 node_modules、构建产物、安装包与 .env 密钥。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 10:04:03 +00:00

227 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect } from 'react';
import { Alert, Button, Descriptions, Form, Input, Space, Tag, Typography, message } from 'antd';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { api } from '../api/client';
import { FormDrawer } from './FormDrawer';
import { CostObjectFields } from './CostObjectFields';
import { FileAttachments } from './FileAttachments';
import { belongLabel, CostTargetLink } from './FinanceCells';
import { COST_TYPE_LABEL, LOAN_STATUS_META, money } from './financeStatus';
type Loan = {
id: string;
loanNo: string;
costType: string;
amount: string | number;
remaining?: number;
purpose: string;
status: string;
remark?: string | null;
createdAt?: string;
bidCase?: { id: string; bidNo: string; name?: string; externalNo?: string } | null;
project?: { id?: string; projectNo: string; name?: string } | null;
applicant?: { displayName: string; employee?: { department?: { name?: string } } };
myActions?: { canEdit: boolean; canSubmit: boolean; canDelete?: boolean; canReview: boolean; canOffset: boolean };
};
export default function LoanDetailPage() {
const { id } = useParams();
const [params] = useSearchParams();
const location = useLocation();
const onDesk = location.pathname.startsWith('/finance');
const listPath = onDesk ? '/finance/loans' : '/office/expenses';
const selfPath = onDesk ? '/finance/loans' : '/office/loans';
const isNew = id === 'new' || !id;
const navigate = useNavigate();
const qc = useQueryClient();
const [form] = Form.useForm();
const q = useQuery({
queryKey: ['loan', id],
enabled: !isNew && Boolean(id),
queryFn: () => api.get(`/loans/${id}`),
});
const row = q.data?.data as Loan | undefined;
useEffect(() => {
if (isNew) {
form.setFieldsValue({
costType: params.get('costType') || 'BID',
bidCaseId: params.get('bidCaseId') || undefined,
projectId: params.get('projectId') || undefined,
purpose: params.get('purpose') || undefined,
});
return;
}
if (!row) return;
form.setFieldsValue({
costType: row.costType,
bidCaseId: row.bidCase?.id,
projectId: row.project ? (row as Loan & { project?: { id?: string } }).project?.id : undefined,
amount: Number(row.amount),
purpose: row.purpose,
remark: row.remark || undefined,
});
}, [isNew, row, form, params]);
const saveMut = useMutation({
mutationFn: (body: Record<string, unknown>) => (isNew ? api.post('/loans', body) : api.patch(`/loans/${id}`, body)),
onSuccess: async (res: { data?: { id?: string } }) => {
message.success('已保存');
await qc.invalidateQueries();
if (isNew && res.data?.id) navigate(`${selfPath}/${res.data.id}`, { replace: true });
},
onError: (e: Error) => message.error(e.message),
});
const submitMut = useMutation({
mutationFn: async (savedId?: string) => {
const target = savedId || id;
if (!target || target === 'new') throw new Error('请先保存');
return api.post(`/loans/${target}/submit`);
},
onSuccess: async () => {
message.success('已提交财务确认');
await qc.invalidateQueries();
},
onError: (e: Error) => message.error(e.message),
});
const reviewMut = useMutation({
mutationFn: (body: { result: 'APPROVED' | 'REJECTED'; comment?: string }) => api.post(`/loans/${id}/reviews`, body),
onSuccess: async () => {
message.success('已办理');
await qc.invalidateQueries();
},
onError: (e: Error) => message.error(e.message),
});
const delMut = useMutation({
mutationFn: () => api.delete(`/loans/${id}`),
onSuccess: () => {
message.success('已删除');
navigate(listPath);
},
onError: (e: Error) => message.error(e.message),
});
const meta = LOAN_STATUS_META[row?.status || 'DRAFT'] || LOAN_STATUS_META.DRAFT;
const editable = isNew || row?.myActions?.canEdit;
return (
<FormDrawer
title={isNew ? '申请借款' : row?.loanNo || '借款单'}
extra="取标差旅等先借款。报销时必须冲这笔借款,不能再领一笔。"
open
onClose={() => navigate(listPath)}
hideOk
createdAt={isNew ? undefined : row?.createdAt}
>
{row ? (
<Space style={{ marginBottom: 16 }} wrap>
<Tag color={meta.color}>{meta.text}</Tag>
<Tag>{COST_TYPE_LABEL[row.costType] || row.costType}</Tag>
<span> {money(row.remaining ?? row.amount)}</span>
</Space>
) : null}
{row?.myActions?.canReview ? (
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="请确认借款。通过后申请人待办会出现「报销冲账」。"
action={
<Space>
<Button type="primary" onClick={() => reviewMut.mutate({ result: 'APPROVED' })}>
</Button>
<Button
danger
onClick={() => {
const comment = window.prompt('打回意见');
if (comment) reviewMut.mutate({ result: 'REJECTED', comment });
}}
>
</Button>
</Space>
}
/>
) : null}
{row?.myActions?.canOffset ? (
<Alert
type="warning"
showIcon
style={{ marginBottom: 16 }}
message="借款未还,请报销冲账,不要另报一笔。"
action={
<Button type="primary">
<Link to={`/office/expenses/new?loanId=${row.id}&costType=${row.costType}&bidCaseId=${row.bidCase?.id || ''}`} style={{ color: 'inherit' }}>
</Link>
</Button>
}
/>
) : null}
{!editable && row ? (
<Descriptions bordered size="small" column={2}>
<Descriptions.Item label="金额">{money(row.amount)}</Descriptions.Item>
<Descriptions.Item label="未还">{money(row.remaining)}</Descriptions.Item>
<Descriptions.Item label="用途" span={2}>
{row.purpose}
</Descriptions.Item>
<Descriptions.Item label="申请人">{row.applicant?.displayName || '—'}</Descriptions.Item>
<Descriptions.Item label="归属">{belongLabel(row)}</Descriptions.Item>
<Descriptions.Item label="对象">
<CostTargetLink costType={row.costType} bid={row.bidCase} project={row.project} />
</Descriptions.Item>
<Descriptions.Item label="备注" span={2}>
{row.remark || '—'}
</Descriptions.Item>
</Descriptions>
) : (
<Form form={form} layout="vertical" style={{ maxWidth: 640 }} onFinish={(v) => saveMut.mutate(v)}>
<CostObjectFields />
<Form.Item name="purpose" label="用途" rules={[{ required: true, message: '请填写用途' }]}>
<Input placeholder="如:取标差旅、预办证照" />
</Form.Item>
<FileAttachments bizType="LOAN" bizId={isNew ? undefined : id} disabled={!editable} label="借据 / 凭证" />
<Space>
<Button type="primary" htmlType="submit" loading={saveMut.isPending}>
稿
</Button>
<Button
loading={submitMut.isPending}
onClick={async () => {
const v = await form.validateFields();
if (isNew) {
const res = await saveMut.mutateAsync(v);
const newId = (res as { data?: { id?: string } }).data?.id;
if (newId) await submitMut.mutateAsync(newId);
} else {
await saveMut.mutateAsync(v);
await submitMut.mutateAsync(id);
}
}}
>
</Button>
{row?.myActions?.canDelete ? (
<Button danger onClick={() => delMut.mutate()}>
</Button>
) : null}
</Space>
</Form>
)}
{!editable && row ? <FileAttachments bizType="LOAN" bizId={id} disabled label="借据 / 凭证" /> : null}
{!editable && row?.myActions?.canSubmit ? (
<Button type="primary" style={{ marginTop: 16 }} onClick={() => submitMut.mutate()}>
</Button>
) : null}
<Typography.Paragraph type="secondary" style={{ marginTop: 24 }}>
</Typography.Paragraph>
</FormDrawer>
);
}