76f266645d
含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。 排除 node_modules、构建产物、安装包与 .env 密钥。 Co-authored-by: Cursor <cursoragent@cursor.com>
227 lines
8.6 KiB
TypeScript
227 lines
8.6 KiB
TypeScript
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>
|
||
);
|
||
}
|