Initial commit: 风影 OA 全栈源码(API/Web/Flutter/IM)
含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。 排除 node_modules、构建产物、安装包与 .env 密钥。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user