备份:修复前完整项目快照 2026-04-19
This commit is contained in:
@@ -0,0 +1,404 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Table, Card, Tag, Button, Space, Input, DatePicker, Select, message, Descriptions, Divider, Tabs, Modal, Result } from 'antd';
|
||||
import { EyeOutlined, CalendarOutlined, UserOutlined, FilterOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
|
||||
const { Option } = Select;
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
const AdvanceVerificationStatusPage: React.FC = () => {
|
||||
const { user } = useAuthStore();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [detailModalVisible, setDetailModalVisible] = useState(false);
|
||||
const [selectedAdvance, setSelectedAdvance] = useState<any>(null);
|
||||
const [unsettledAdvances, setUnsettledAdvances] = useState<any[]>([]);
|
||||
const [settledAdvances, setSettledAdvances] = useState<any[]>([]);
|
||||
const [searchName, setSearchName] = useState('');
|
||||
const [dateRange, setDateRange] = useState<[dayjs.Dayjs, dayjs.Dayjs] | null>(null);
|
||||
const [sortField, setSortField] = useState('');
|
||||
const [sortOrder, setSortOrder] = useState('');
|
||||
|
||||
// 检查权限
|
||||
const hasPermission = user?.role === 'admin' || user?.department === '财务部';
|
||||
|
||||
if (!hasPermission) {
|
||||
return (
|
||||
<div style={{ padding: 24, textAlign: 'center' }}>
|
||||
<Result
|
||||
status="403"
|
||||
title="无权限访问"
|
||||
subTitle="您没有权限访问此页面,只有管理员和财务人员可以查看预支核销状态。"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 获取未核销和已核销的预支单
|
||||
const fetchAdvances = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// 获取所有预支单
|
||||
const res = await fetch('/api/advances');
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
// 处理数据,确保每个预支单都有total_reimbursed字段
|
||||
const processedAdvances = data.data.map((advance: any) => ({
|
||||
...advance,
|
||||
total_reimbursed: advance.total_reimbursed || 0,
|
||||
isSettled: advance.status === 'settled' || (advance.total_reimbursed || 0) >= advance.amount
|
||||
}));
|
||||
|
||||
// 分离未核销和已核销的预支单
|
||||
const unsettled = processedAdvances.filter((advance: any) => !advance.isSettled && (advance.status === 'approved' || advance.status === 'executed' || advance.status === 'partial_verification'));
|
||||
const settled = processedAdvances.filter((advance: any) => advance.isSettled || advance.status === 'settled' || advance.status === 'completed');
|
||||
|
||||
setUnsettledAdvances(unsettled);
|
||||
setSettledAdvances(settled);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('获取预支单失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取预支单详情,包括关联的核销单
|
||||
const fetchAdvanceDetail = async (advanceId: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// 获取预支单详情
|
||||
const advanceRes = await fetch(`/api/advances/${advanceId}`);
|
||||
const advanceData = await advanceRes.json();
|
||||
|
||||
if (advanceData.success) {
|
||||
// 获取关联的核销单
|
||||
const verificationRes = await fetch(`/api/verifications?advance_id=${advanceId}`);
|
||||
const verificationData = await verificationRes.json();
|
||||
|
||||
if (verificationData.success) {
|
||||
// 只保留已执行或已批准的核销单,并且关联的预支单编号与当前预支单一致
|
||||
const validVerifications = (verificationData.data || []).filter((verification: any) =>
|
||||
(verification.status === 'approved' || verification.status === 'executed') &&
|
||||
verification.advance_code === advanceData.data.advance_code
|
||||
);
|
||||
|
||||
setSelectedAdvance({
|
||||
...advanceData.data,
|
||||
verifications: validVerifications
|
||||
});
|
||||
setDetailModalVisible(true);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('获取预支单详情失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchAdvances();
|
||||
}, []);
|
||||
|
||||
const formatAmount = (amount: number, currency: string = 'CNY') => {
|
||||
const symbols: Record<string, string> = { CNY: '¥', USD: '$', LAK: '₭', THB: '฿' };
|
||||
return (symbols[currency] || '¥') + (amount || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
};
|
||||
|
||||
const getStatusTag = (status: string) => {
|
||||
const statusMap: Record<string, { color: string; text: string }> = {
|
||||
pending: { color: 'processing', text: '待审批' },
|
||||
approved: { color: 'success', text: '已批准' },
|
||||
rejected: { color: 'error', text: '已退回' },
|
||||
withdrawn: { color: 'default', text: '已撤回' },
|
||||
settled: { color: 'blue', text: '已核销' },
|
||||
pending_edit: { color: 'warning', text: '待编辑' },
|
||||
partial_verification: { color: 'orange', text: '部分核销' },
|
||||
completed: { color: 'green', text: '已完成' },
|
||||
};
|
||||
const config = statusMap[status] || { color: 'default', text: status };
|
||||
return <Tag color={config.color}>{config.text}</Tag>;
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
// 这里可以添加搜索逻辑
|
||||
fetchAdvances();
|
||||
};
|
||||
|
||||
const handleSort = (field: string, order: string) => {
|
||||
setSortField(field);
|
||||
setSortOrder(order);
|
||||
// 这里可以添加排序逻辑
|
||||
fetchAdvances();
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '事由',
|
||||
dataIndex: 'reason',
|
||||
key: 'reason',
|
||||
ellipsis: true,
|
||||
render: (v: string, r: any) => (
|
||||
<a onClick={() => fetchAdvanceDetail(r.id)}>{v}</a>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '申请人',
|
||||
dataIndex: 'applicant',
|
||||
key: 'applicant',
|
||||
width: 100,
|
||||
sorter: (a: any, b: any) => a.applicant.localeCompare(b.applicant),
|
||||
onHeaderCell: (column: any) => ({
|
||||
onClick: () => handleSort('applicant', sortOrder === 'ascend' ? 'descend' : 'ascend')
|
||||
})
|
||||
},
|
||||
{
|
||||
title: '预支金额',
|
||||
dataIndex: 'amount',
|
||||
key: 'amount',
|
||||
width: 140,
|
||||
render: (v: number, r: any) => formatAmount(v, r.currency),
|
||||
sorter: (a: any, b: any) => a.amount - b.amount,
|
||||
onHeaderCell: (column: any) => ({
|
||||
onClick: () => handleSort('amount', sortOrder === 'ascend' ? 'descend' : 'ascend')
|
||||
})
|
||||
},
|
||||
{
|
||||
title: '已核销金额',
|
||||
dataIndex: 'total_reimbursed',
|
||||
key: 'total_reimbursed',
|
||||
width: 140,
|
||||
render: (v: number, r: any) => formatAmount(v || 0, r.currency),
|
||||
sorter: (a: any, b: any) => (a.total_reimbursed || 0) - (b.total_reimbursed || 0),
|
||||
onHeaderCell: (column: any) => ({
|
||||
onClick: () => handleSort('total_reimbursed', sortOrder === 'ascend' ? 'descend' : 'ascend')
|
||||
})
|
||||
},
|
||||
{
|
||||
title: '剩余金额',
|
||||
dataIndex: 'remaining',
|
||||
key: 'remaining',
|
||||
width: 140,
|
||||
render: (_, r: any) => formatAmount((r.amount || 0) - (r.total_reimbursed || 0), r.currency),
|
||||
sorter: (a: any, b: any) => ((a.amount || 0) - (a.total_reimbursed || 0)) - ((b.amount || 0) - (b.total_reimbursed || 0)),
|
||||
onHeaderCell: (column: any) => ({
|
||||
onClick: () => handleSort('remaining', sortOrder === 'ascend' ? 'descend' : 'ascend')
|
||||
})
|
||||
},
|
||||
{
|
||||
title: '预支日期',
|
||||
dataIndex: 'advance_date',
|
||||
key: 'advance_date',
|
||||
width: 120,
|
||||
sorter: (a: any, b: any) => new Date(a.advance_date).getTime() - new Date(b.advance_date).getTime(),
|
||||
onHeaderCell: (column: any) => ({
|
||||
onClick: () => handleSort('advance_date', sortOrder === 'ascend' ? 'descend' : 'ascend')
|
||||
})
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (status: string) => getStatusTag(status)
|
||||
},
|
||||
{
|
||||
title: '编号',
|
||||
dataIndex: 'advance_code',
|
||||
key: 'advance_code',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 80,
|
||||
render: (_: any, record: any) => (
|
||||
<Space wrap>
|
||||
<Button size="small" icon={<EyeOutlined />} onClick={() => fetchAdvanceDetail(record.id)}>详情</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<h2 style={{ marginBottom: 8 }}>预支核销状态管理</h2>
|
||||
<p style={{ color: '#888', marginBottom: 0 }}>管理预支单的核销状态和进度</p>
|
||||
</div>
|
||||
|
||||
{/* 搜索和筛选区域 */}
|
||||
<Card style={{ marginBottom: 24 }}>
|
||||
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
||||
<div style={{ flex: 1, minWidth: 200 }}>
|
||||
<Input
|
||||
placeholder="按申请人姓名搜索"
|
||||
prefix={<UserOutlined />}
|
||||
value={searchName}
|
||||
onChange={(e) => setSearchName(e.target.value)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ width: 300 }}>
|
||||
<RangePicker
|
||||
placeholder={['开始日期', '结束日期']}
|
||||
onChange={(dates) => setDateRange(dates)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<Button type="primary" icon={<FilterOutlined />} onClick={handleSearch}>
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 预支单列表 */}
|
||||
<Card>
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'unsettled',
|
||||
label: '未核销完成',
|
||||
children: (
|
||||
<Table
|
||||
dataSource={unsettledAdvances}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 20 }}
|
||||
scroll={{ x: 1200 }}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'settled',
|
||||
label: '已完结',
|
||||
children: (
|
||||
<Table
|
||||
dataSource={settledAdvances}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 20 }}
|
||||
scroll={{ x: 1200 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 详情模态框 */}
|
||||
<Modal
|
||||
title={`预支单详情:${selectedAdvance?.advance_code}`}
|
||||
open={detailModalVisible}
|
||||
onCancel={() => setDetailModalVisible(false)}
|
||||
footer={[
|
||||
<Button key="close" onClick={() => setDetailModalVisible(false)}>关闭</Button>
|
||||
]}
|
||||
width={900}
|
||||
>
|
||||
{selectedAdvance && (
|
||||
<>
|
||||
{/* 预支单基本信息 */}
|
||||
<Descriptions bordered column={2} size="small">
|
||||
<Descriptions.Item label="预支编号">{selectedAdvance.advance_code}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">{getStatusTag(selectedAdvance.status)}</Descriptions.Item>
|
||||
<Descriptions.Item label="申请人">{selectedAdvance.applicant}</Descriptions.Item>
|
||||
<Descriptions.Item label="预支日期">{selectedAdvance.advance_date}</Descriptions.Item>
|
||||
<Descriptions.Item label="金额">
|
||||
{formatAmount(selectedAdvance.amount, selectedAdvance.currency)}
|
||||
{selectedAdvance.currency !== 'CNY' && selectedAdvance.amount_cny && (
|
||||
<span style={{ color: '#999', marginLeft: 8 }}>≈ ¥{selectedAdvance.amount_cny.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</span>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="已核销金额">{formatAmount(selectedAdvance.total_reimbursed || 0, selectedAdvance.currency)}</Descriptions.Item>
|
||||
<Descriptions.Item label="剩余金额">{formatAmount((selectedAdvance.amount || 0) - (selectedAdvance.total_reimbursed || 0), selectedAdvance.currency)}</Descriptions.Item>
|
||||
<Descriptions.Item label="币种">{selectedAdvance.currency}</Descriptions.Item>
|
||||
<Descriptions.Item label="事由" span={2}>{selectedAdvance.reason}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
{/* 关联的核销单 */}
|
||||
{selectedAdvance.verifications && selectedAdvance.verifications.length > 0 && (
|
||||
<>
|
||||
<Divider>关联核销单</Divider>
|
||||
<Table
|
||||
dataSource={selectedAdvance.verifications}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
columns={[
|
||||
{
|
||||
title: '核销编号',
|
||||
dataIndex: 'verification_code',
|
||||
key: 'verification_code'
|
||||
},
|
||||
{
|
||||
title: '关联预支单',
|
||||
dataIndex: 'advance_code',
|
||||
key: 'advance_code'
|
||||
},
|
||||
{
|
||||
title: '核销金额',
|
||||
dataIndex: 'amount',
|
||||
key: 'amount',
|
||||
render: (v: number, r: any) => formatAmount(v, r.currency)
|
||||
},
|
||||
{
|
||||
title: '核销日期',
|
||||
dataIndex: 'verification_date',
|
||||
key: 'verification_date'
|
||||
},
|
||||
{
|
||||
title: '是否结算',
|
||||
dataIndex: 'settlement',
|
||||
key: 'settlement',
|
||||
render: (v: boolean) => (
|
||||
<Tag color={v ? 'green' : 'orange'}>{v ? '是' : '否'}</Tag>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status: string) => getStatusTag(status)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 附件 */}
|
||||
{selectedAdvance.attachments && selectedAdvance.attachments.length > 0 && (
|
||||
<>
|
||||
<Divider>凭证附件</Divider>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{selectedAdvance.attachments.map((url: string, index: number) => (
|
||||
<div key={index} style={{ position: 'relative' }}>
|
||||
{url && url.match(/\.(jpg|jpeg|png|gif|webp)$/i) ? (
|
||||
<img
|
||||
src={url}
|
||||
alt={`附件${index + 1}`}
|
||||
style={{ width: 120, height: 120, objectFit: 'cover', borderRadius: 4, border: '1px solid #f0f0f0', cursor: 'pointer' }}
|
||||
onClick={() => window.open(url, '_blank')}
|
||||
/>
|
||||
) : (
|
||||
<a href={url} target="_blank" rel="noopener noreferrer">
|
||||
<div style={{ width: 120, height: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', border: '1px solid #f0f0f0', borderRadius: 4, background: '#f5f5f5' }}>
|
||||
<span style={{ color: '#666' }}>附件 {index + 1}</span>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdvanceVerificationStatusPage;
|
||||
@@ -0,0 +1,437 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Table, Button, Card, Space, Tag, Modal, Form, Input, InputNumber, Select, DatePicker, message, Descriptions, Image, Divider, Tabs } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, EyeOutlined, UndoOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import FileUpload from '../../components/FileUpload';
|
||||
|
||||
const { Option } = Select;
|
||||
const { TextArea } = Input;
|
||||
|
||||
const AdvancesPage: React.FC = () => {
|
||||
const { user } = useAuthStore();
|
||||
const [advances, setAdvances] = useState<any[]>([]);
|
||||
const [completedAdvances, setCompletedAdvances] = useState<any[]>([]);
|
||||
const [activeTab, setActiveTab] = useState('active');
|
||||
const [projects, setProjects] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [detailModalVisible, setDetailModalVisible] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [selectedRecord, setSelectedRecord] = useState<any>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [deleteForm] = Form.useForm();
|
||||
const [currentEditingStatus, setCurrentEditingStatus] = useState<string>('');
|
||||
const [exchangeRates, setExchangeRates] = useState<Record<string, number>>({});
|
||||
|
||||
useEffect(() => {
|
||||
fetchAdvances();
|
||||
fetchProjects();
|
||||
fetchExchangeRates();
|
||||
}, []);
|
||||
|
||||
const fetchAdvances = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/advances');
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
// 分离活跃的和已完结的预支
|
||||
const active = data.data.filter((item: any) => ['pending', 'approved', 'rejected', 'withdrawn', 'pending_edit'].includes(item.status));
|
||||
const completed = data.data.filter((item: any) => ['executed', 'settled'].includes(item.status));
|
||||
setAdvances(active);
|
||||
setCompletedAdvances(completed);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('获取预支列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchProjects = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/projects');
|
||||
const data = await res.json();
|
||||
if (data.success) setProjects(data.data);
|
||||
} catch (error) {}
|
||||
};
|
||||
|
||||
const fetchExchangeRates = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/exchange-rates/latest');
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
const rates: Record<string, number> = {};
|
||||
Object.keys(data.data).forEach(key => {
|
||||
rates[key] = parseFloat(data.data[key]) || 1;
|
||||
});
|
||||
setExchangeRates(rates);
|
||||
}
|
||||
} catch (error) {}
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingId(null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
advance_date: dayjs(),
|
||||
currency: 'CNY',
|
||||
applicant: user?.name || user?.username || '当前用户',
|
||||
attachments: []
|
||||
});
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleEdit = (record: any) => {
|
||||
setEditingId(record.id);
|
||||
setCurrentEditingStatus(record.status);
|
||||
form.setFieldsValue({
|
||||
...record,
|
||||
advance_date: record.advance_date ? dayjs(record.advance_date) : null,
|
||||
attachments: record.attachments || []
|
||||
});
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleView = async (record: any) => {
|
||||
try {
|
||||
const res = await fetch(`/api/advances/${record.id}`);
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setSelectedRecord(data.data);
|
||||
setDetailModalVisible(true);
|
||||
} else {
|
||||
message.error('获取详情失败');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('获取详情失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
// 重置删除表单
|
||||
deleteForm.resetFields();
|
||||
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: (
|
||||
<Form form={deleteForm} layout="vertical">
|
||||
<Form.Item
|
||||
name="password"
|
||||
label="请输入密码确认删除"
|
||||
rules={[{ required: true, message: '请输入密码' }]}
|
||||
>
|
||||
<Input.Password placeholder="输入密码" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
),
|
||||
onOk: async () => {
|
||||
try {
|
||||
const values = await deleteForm.validateFields();
|
||||
// 这里可以添加密码验证逻辑,暂时直接删除
|
||||
await fetch('/api/advances/' + id, { method: 'DELETE' });
|
||||
message.success('删除成功');
|
||||
fetchAdvances();
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleWithdraw = async (id: number) => {
|
||||
Modal.confirm({
|
||||
title: '确认撤回',
|
||||
content: '撤回后可重新编辑提交,确认撤回吗?',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await fetch('/api/advances/' + id + '/withdraw', { method: 'POST' });
|
||||
message.success('已撤回,可重新编辑');
|
||||
fetchAdvances();
|
||||
} catch (error) {
|
||||
message.error('撤回失败');
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 保存操作:只保存信息,不改变状态
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
// 保存时使用编辑时的状态
|
||||
const saveStatus = currentEditingStatus || 'pending_edit';
|
||||
console.log('保存操作 - 状态:', saveStatus);
|
||||
console.log('currentEditingStatus:', currentEditingStatus);
|
||||
const data = {
|
||||
...values,
|
||||
advance_date: values.advance_date?.format('YYYY-MM-DD'),
|
||||
amount_cny: values.currency === 'CNY' ? values.amount : convertToCNY(values.amount, values.currency),
|
||||
applicant: user?.name || user?.username,
|
||||
status: saveStatus
|
||||
};
|
||||
console.log('保存操作 - 提交的数据:', data);
|
||||
const url = editingId ? '/api/advances/' + editingId : '/api/advances';
|
||||
const method = editingId ? 'PUT' : 'POST';
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
const result = await res.json();
|
||||
console.log('保存操作 - 响应:', result);
|
||||
if (result.success) {
|
||||
message.success(editingId ? '保存成功' : '创建成功');
|
||||
setModalVisible(false);
|
||||
fetchAdvances();
|
||||
} else {
|
||||
message.error(result.error || '保存失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存操作 - 错误:', error);
|
||||
message.error('保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 提交操作:提交到待审批状态
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
// 提交时使用pending状态
|
||||
const saveStatus = 'pending';
|
||||
console.log('提交操作 - 状态:', saveStatus);
|
||||
const data = {
|
||||
...values,
|
||||
advance_date: values.advance_date?.format('YYYY-MM-DD'),
|
||||
amount_cny: values.currency === 'CNY' ? values.amount : convertToCNY(values.amount, values.currency),
|
||||
applicant: user?.name || user?.username,
|
||||
status: saveStatus
|
||||
};
|
||||
console.log('提交操作 - 提交的数据:', data);
|
||||
const url = editingId ? '/api/advances/' + editingId : '/api/advances';
|
||||
const method = editingId ? 'PUT' : 'POST';
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
const result = await res.json();
|
||||
console.log('提交操作 - 响应:', result);
|
||||
if (result.success) {
|
||||
message.success(editingId ? '提交成功' : '创建成功');
|
||||
setModalVisible(false);
|
||||
fetchAdvances();
|
||||
} else {
|
||||
message.error(result.error || '提交失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('提交操作 - 错误:', error);
|
||||
message.error('提交失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitAndSubmit = async () => {
|
||||
await handleSubmit();
|
||||
};
|
||||
|
||||
const convertToCNY = (amount: number, currency: string): number => {
|
||||
if (currency === 'CNY') return amount;
|
||||
const rateKey = 'CNY_' + currency;
|
||||
const rate = exchangeRates[rateKey] || 1;
|
||||
return amount / rate;
|
||||
};
|
||||
|
||||
const amount = Form.useWatch('amount', form);
|
||||
const currency = Form.useWatch('currency', form);
|
||||
const amountCNY = React.useMemo(() => {
|
||||
return amount && currency ? convertToCNY(amount, currency) : 0;
|
||||
}, [amount, currency, exchangeRates]);
|
||||
|
||||
const getStatusTag = (status: string) => {
|
||||
const statusMap: Record<string, { color: string; text: string }> = {
|
||||
pending: { color: 'processing', text: '待审批' },
|
||||
approved: { color: 'success', text: '已批准' },
|
||||
rejected: { color: 'error', text: '已退回' },
|
||||
withdrawn: { color: 'default', text: '已撤回' },
|
||||
settled: { color: 'blue', text: '已核销' },
|
||||
pending_edit: { color: 'warning', text: '待编辑' },
|
||||
};
|
||||
const config = statusMap[status] || { color: 'default', text: status };
|
||||
return <Tag color={config.color}>{config.text}</Tag>;
|
||||
};
|
||||
|
||||
const formatAmount = (amount: number, currency: string = 'CNY') => {
|
||||
const symbols: Record<string, string> = { CNY: '¥', USD: '$', LAK: '₭', THB: '฿' };
|
||||
return (symbols[currency] || '¥') + (amount || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
};
|
||||
|
||||
// Format number with thousand separator for input display
|
||||
const formatNumberWithSeparator = (value: number | undefined, currency: string): string => {
|
||||
if (value === undefined || value === null) return '';
|
||||
const symbols: Record<string, string> = { CNY: '¥', USD: '$', LAK: '₭', THB: '฿' };
|
||||
const symbol = symbols[currency] || '¥';
|
||||
return symbol + value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
};
|
||||
|
||||
// Parse formatted string back to number
|
||||
const parseFormattedNumber = (value: string): number => {
|
||||
// Remove currency symbols and thousand separators
|
||||
const cleaned = value.replace(/[¥$₭฿,]/g, '');
|
||||
return parseFloat(cleaned) || 0;
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '事由', dataIndex: 'reason', key: 'reason', ellipsis: true, render: (v: string, r: any) => <a onClick={() => handleView(r)}>{v}</a> },
|
||||
{ title: '申请人', dataIndex: 'applicant', key: 'applicant', width: 80 },
|
||||
{ title: '金额', dataIndex: 'amount', key: 'amount', width: 140, render: (v: number, r: any) => (
|
||||
<>
|
||||
<div>{formatAmount(v, r.currency)}</div>
|
||||
{r.currency !== 'CNY' && r.amount_cny && <div style={{ color: '#999', fontSize: 12 }}>≈ ¥{r.amount_cny.toLocaleString('zh-CN', {minimumFractionDigits: 2})}</div>}
|
||||
</>
|
||||
) },
|
||||
{ title: '预支日期', dataIndex: 'advance_date', key: 'advance_date', width: 100 },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 100, render: (status: string) => getStatusTag(status) },
|
||||
{ title: '编号', dataIndex: 'advance_code', key: 'advance_code', width: 120 },
|
||||
{
|
||||
title: '操作', key: 'action', width: 250,
|
||||
render: (_: any, record: any) => (
|
||||
<Space wrap>
|
||||
<Button size="small" icon={<EyeOutlined />} onClick={() => handleView(record)}>详情</Button>
|
||||
{record.status === 'pending' && (
|
||||
<>
|
||||
<Button size="small" icon={<UndoOutlined />} onClick={() => handleWithdraw(record.id)}>撤回</Button>
|
||||
</>
|
||||
)}
|
||||
{(record.status === 'rejected' || record.status === 'withdrawn' || record.status === 'pending_edit') && (
|
||||
<Button size="small" type="primary" icon={<EditOutlined />} onClick={() => handleEdit(record)}>编辑重提</Button>
|
||||
)}
|
||||
<Button size="small" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.id)}>删除</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<h2 style={{ marginBottom: 8 }}>预支申请</h2>
|
||||
<p style={{ color: '#888', marginBottom: 0 }}>管理员工预支申请</p>
|
||||
</div>
|
||||
|
||||
<Card extra={<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>新建预支</Button>}>
|
||||
<Tabs activeKey={activeTab} onChange={setActiveTab}>
|
||||
<Tabs.TabPane tab="活跃申请" key="active">
|
||||
<Table dataSource={advances} columns={columns} rowKey="id" loading={loading} pagination={{ pageSize: 20 }} size="middle" scroll={{ x: 1200 }} />
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab="已完结" key="completed">
|
||||
<Table dataSource={completedAdvances} columns={columns} rowKey="id" loading={loading} pagination={{ pageSize: 20 }} size="middle" scroll={{ x: 1200 }} />
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
</Card>
|
||||
|
||||
{/* 新建/编辑弹窗 */}
|
||||
<Modal
|
||||
title={editingId ? '编辑预支' : '新建预支'}
|
||||
open={modalVisible}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
footer={[
|
||||
<Button key="cancel" onClick={() => setModalVisible(false)}>取消</Button>,
|
||||
<Button key="save" onClick={handleSave}>保存</Button>,
|
||||
<Button key="submit" type="primary" onClick={handleSubmitAndSubmit}>提交</Button>
|
||||
]}
|
||||
width={700}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="applicant" label="申请人">
|
||||
<Input disabled style={{ color: 'rgba(0,0,0,0.85)', backgroundColor: '#f5f5f5' }} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="advance_date" label="预支日期" rules={[{ required: true }]}>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
|
||||
|
||||
|
||||
<Form.Item label="金额" required>
|
||||
<Space>
|
||||
<Form.Item name="currency" noStyle initialValue="CNY">
|
||||
<Select style={{ width: 140 }}>
|
||||
<Option value="CNY">人民币 (CNY)</Option>
|
||||
<Option value="USD">美元 (USD)</Option>
|
||||
<Option value="LAK">老挝基普 (LAK)</Option>
|
||||
<Option value="THB">泰铢 (THB)</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item name="amount" noStyle rules={[{ required: true, message: '请输入金额' }]}>
|
||||
<InputNumber
|
||||
style={{ width: 200 }}
|
||||
min={0}
|
||||
precision={2}
|
||||
placeholder="输入金额"
|
||||
formatter={(value) => formatNumberWithSeparator(value as number, currency || 'CNY')}
|
||||
parser={(value) => parseFormattedNumber(value || '0')}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Space>
|
||||
{amountCNY > 0 && (
|
||||
<div style={{ marginTop: 8, color: '#888', fontSize: 13 }}>
|
||||
等价人民币:¥ {amountCNY.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</div>
|
||||
)}
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="reason" label="事由" rules={[{ required: true }]}>
|
||||
<TextArea rows={3} placeholder="请输入预支事由" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="attachments" label="凭证附件">
|
||||
<FileUpload
|
||||
value={form.getFieldValue('attachments')}
|
||||
onChange={(urls) => form.setFieldsValue({ attachments: urls })}
|
||||
maxCount={9}
|
||||
accept="image/*"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 详情弹窗 */}
|
||||
<Modal title="预支详情" open={detailModalVisible} onCancel={() => setDetailModalVisible(false)} footer={null} width={800}>
|
||||
{selectedRecord && (
|
||||
<>
|
||||
<Descriptions bordered column={2} size="small">
|
||||
<Descriptions.Item label="预支编号">{selectedRecord.advance_code}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">{getStatusTag(selectedRecord.status)}</Descriptions.Item>
|
||||
<Descriptions.Item label="申请人">{selectedRecord.applicant}</Descriptions.Item>
|
||||
<Descriptions.Item label="预支日期">{selectedRecord.advance_date}</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label="金额">
|
||||
{formatAmount(selectedRecord.amount, selectedRecord.currency)}
|
||||
{selectedRecord.currency !== 'CNY' && selectedRecord.amount_cny && (
|
||||
<span style={{ color: '#999', marginLeft: 8 }}>≈ ¥{selectedRecord.amount_cny.toLocaleString('zh-CN', {minimumFractionDigits: 2})}</span>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="事由" span={2}>{selectedRecord.reason}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
{Array.isArray(selectedRecord.attachments) && selectedRecord.attachments.length > 0 && (
|
||||
<>
|
||||
<Divider>凭证附件</Divider>
|
||||
<Image.PreviewGroup>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{selectedRecord.attachments.map((url: string, index: number) => (
|
||||
<Image key={index} src={url} width={100} height={100} style={{ objectFit: 'cover', borderRadius: 4 }} />
|
||||
))}
|
||||
</div>
|
||||
</Image.PreviewGroup>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdvancesPage;
|
||||
Reference in New Issue
Block a user