Files
yunhaifinance/frontend/src/pages/approval/ExecutionManagement.tsx
T
a273825743 706dcc24eb 备份:PWA配置前的完整版本
包含所有最新开发的功能和修复,作为PWA正确配置前的备份点。
2026-06-13 12:44:48 +08:00

1007 lines
46 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 React, { useState, useEffect } from 'react';
import { Card, Table, Tag, Button, Space, Modal, Form, Input, Select, DatePicker, message, Tabs, Badge, Descriptions, Divider, List, Upload } from 'antd';
import { CheckOutlined, CloseOutlined, EyeOutlined, DollarOutlined, EditOutlined, UndoOutlined, ClockCircleOutlined, FileImageOutlined, UploadOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { useLanguageStore } from '../../store/languageStore';
const { TextArea } = Input;
// 项目支出分类
const PROJECT_EXPENSE_CATEGORIES = [
{ value: 'material_purchase', label: '材料采购' },
{ value: 'equipment_purchase', label: '设备采购' },
{ value: 'pole_crossarm', label: '电杆横担支出' },
{ value: 'freight', label: '运费支出' },
{ value: 'construction', label: '施工费支出' },
{ value: 'other', label: '其他支出' }
];
// 公司支出分类
const COMPANY_EXPENSE_CATEGORIES = [
{ value: 'office_operations', label: '通用运营(房租/耗材)' },
{ value: 'transportation', label: '交通通勤' },
{ value: 'marketing', label: '业扩营销' },
{ value: 'power_system', label: '电力系统关系' },
{ value: 'employee_welfare', label: '员工福利' },
{ value: 'logistics', label: '快递物流' },
{ value: 'other', label: '其他支出' }
];
// Type name to i18n key mapping
const typeKeyMap: Record<string, string> = {
'预支申请': 'execution.advanceApply',
'报销申请': 'execution.reimburseApply',
'付款申请': 'execution.paymentApply',
'核销申请': 'execution.verificationApply',
'采购申请': 'execution.purchaseApply',
};
// 执行记录类型
interface ExecutionRecord {
id: string;
applyCode: string;
applyType: string;
applicant: string;
amount: number;
currency: string;
action: 'execute' | 'reject';
operator: string;
operatorRole: string;
timestamp: string;
executeMethod?: string;
voucherNo?: string;
rejectReason?: string;
remark?: string;
}
const ExecutionManagement: React.FC = () => {
const { t, currentLanguage } = useLanguageStore();
const [loading, setLoading] = useState(false);
const [detailModalVisible, setDetailModalVisible] = useState(false);
const [editModalVisible, setEditModalVisible] = useState(false);
const [selectedRecord, setSelectedRecord] = useState<any>(null);
const [fullDetail, setFullDetail] = useState<any>(null);
const [form] = Form.useForm();
const [editForm] = Form.useForm();
const [voucherFiles, setVoucherFiles] = useState<any[]>([]);
const [isRejecting, setIsRejecting] = useState(false);
// 待执行数据
const [pendingData, setPendingData] = useState([]);
// 已执行数据
const [executedData, setExecutedData] = useState([]);
// 已执行列表筛选状态
const [searchKeyword, setSearchKeyword] = useState('');
const [filterType, setFilterType] = useState<string | null>(null);
const [sortField, setSortField] = useState<string>('executeDate');
const [sortOrder, setSortOrder] = useState<'ascend' | 'descend'>('descend');
// 项目列表
const [projects, setProjects] = useState<any[]>([]);
// 从后端获取待执行数据
useEffect(() => {
const fetchPendingData = async () => {
setLoading(true);
try {
const response = await fetch('/api/executions/pending');
if (response.ok) {
const data = await response.json();
if (data.success && Array.isArray(data.data)) {
setPendingData(data.data.map((item: any, index: number) => ({
...item,
key: item.id || index,
rawData: item
})));
} else {
message.error(t('execution.getPendingFailedFormat'));
}
} else {
message.error(t('execution.getPendingFailed') + response.statusText);
}
} catch (error) {
console.error('获取待执行数据错误:', error);
message.error(t('execution.getPendingNetworkError'));
} finally {
setLoading(false);
}
};
fetchPendingData();
}, []);
// 获取项目列表
useEffect(() => {
const fetchProjects = async () => {
try {
const response = await fetch('/api/projects');
if (response.ok) {
const data = await response.json();
if (data.success && Array.isArray(data.data)) {
setProjects(data.data);
}
}
} catch (error) {
console.error('获取项目列表失败:', error);
}
};
fetchProjects();
}, []);
// 从后端获取已执行数据
useEffect(() => {
const fetchExecutedData = async () => {
setLoading(true);
try {
const response = await fetch('/api/executions/executed');
if (response.ok) {
const data = await response.json();
if (data.success && Array.isArray(data.data)) {
setExecutedData(data.data.map((item: any, index: number) => ({
...item,
key: item.id || index,
rawData: item
})));
} else {
message.error(t('execution.getExecutedFailedFormat'));
}
} else {
message.error(t('execution.getExecutedFailed') + response.statusText);
}
} catch (error) {
console.error('获取已执行数据错误:', error);
message.error(t('execution.getExecutedNetworkError'));
} finally {
setLoading(false);
}
};
fetchExecutedData();
}, []);
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 formatNumberWithSeparator = (value: number | undefined, currency: string): string => {
if (value === undefined || value === null) return '';
const symbols: Record<string, string> = { CNY: '¥', USD: '$', LAK: '₭', THB: '฿' };
return (symbols[currency] || '¥') + value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
};
const parseFormattedNumber = (value: string): number => {
const cleaned = value.replace(/[¥$₭฿,]/g, '');
return parseFloat(cleaned) || 0;
};
const getTypeTag = (type: string) => {
const colors: Record<string, string> = { '预支申请': 'blue', '报销申请': 'green', '付款申请': 'orange', '核销申请': 'purple', '采购申请': 'cyan' };
const key = typeKeyMap[type] || type;
return <Tag color={colors[type] || 'default'}>{t(key)}</Tag>;
};
const getStatusTag = (status: string) => {
const statusMap: Record<string, { color: string; text: string }> = {
pending: { color: 'processing', text: t('execution.pendingExecution') },
executed: { color: 'success', text: t('execution.executed') },
rejected: { color: 'error', text: t('execution.rejected') },
approved: { color: 'success', text: t('execution.approved') },
};
const config = statusMap[status] || { color: 'default', text: status };
return <Tag color={config.color}>{config.text}</Tag>;
};
// 根据项目ID获取项目名称
const getProjectName = (projectId: any) => {
if (!projectId) return '-';
const project = projects.find(p => p.id === projectId);
return project ? project.name : `项目ID: ${projectId}`;
};
// 查看详情
const handleViewDetail = async (record: any) => {
setSelectedRecord(record);
form.resetFields();
form.setFieldsValue({ execute_date: dayjs(), execute_method: 'bank' });
setVoucherFiles([]);
setIsRejecting(false);
// 获取完整详情
if (record.type === t('execution.purchaseApply')) {
try {
const response = await fetch(`/api/purchase-requests/${record.id}`);
const data = await response.json();
if (data.success) {
setFullDetail(data.data);
}
} catch (error) {
console.error('获取采购申请详情失败:', error);
// 如果获取失败,使用record中的数据
setFullDetail(record);
}
} else {
// 其他类型使用record中的数据
setFullDetail(record);
}
setDetailModalVisible(true);
};
// 处理执行
const handleExecute = async () => {
try {
// 检查是否为核销申请
const isVerification = selectedRecord.type === t('execution.verificationApply');
// 检查是否为退款类型的核销申请
const isRefundVerification = isVerification && fullDetail.settlement && fullDetail.settlement_amount > 0;
// 检查是否为非结算核销
const isNonSettlementVerification = isVerification && !fullDetail.settlement;
// 非结算核销不需要验证执行方式和付款凭证
if (isNonSettlementVerification) {
// 直接执行,不需要验证表单
setLoading(true);
// 调用执行API
const executeResponse = await fetch('/api/executions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
apply_id: selectedRecord.id,
apply_type: 'verification',
action: 'execute',
execute_method: 'none', // 非结算核销不需要执行方式
voucher_files: [], // 非结算核销不需要付款凭证
remark: form.getFieldValue('remark') || ''
})
});
if (executeResponse.ok) {
setPendingData(pendingData.filter(item => item.key !== selectedRecord.key));
// 刷新已执行数据
const fetchExecutedData = async () => {
try {
const response = await fetch('/api/executions/executed');
if (response.ok) {
const data = await response.json();
if (data.success && Array.isArray(data.data)) {
setExecutedData(data.data.map((item: any, index: number) => ({
...item,
key: item.id || index,
rawData: item
})));
}
}
} catch (error) {
console.error('获取已执行数据错误:', error);
}
};
fetchExecutedData();
message.success(t('execution.executeSuccess', { code: selectedRecord.code }));
setDetailModalVisible(false);
} else {
message.error(t('execution.executeFailed'));
}
} else {
// 其他类型的申请需要验证表单
const values = await form.validateFields();
// 检查是否需要上传付款凭证
if (!isRefundVerification && (!voucherFiles || voucherFiles.length === 0)) {
message.error(t('execution.proofRequired'));
return;
}
setLoading(true);
// 获取已上传文件的URL列表(如果需要)
let voucherFileUrls = [];
if (!isRefundVerification) {
voucherFileUrls = voucherFiles
.map(f => f.url || f.response?.data?.url || f.response?.url)
.filter(url => url); // 过滤掉空值
}
// 调用执行API
const executeResponse = await fetch('/api/executions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
apply_id: selectedRecord.id,
apply_type: selectedRecord.type === t('execution.advanceApply') ? 'advance' : selectedRecord.type === t('execution.reimburseApply') ? 'reimbursement' : selectedRecord.type === t('execution.paymentApply') ? 'payment' : selectedRecord.type === t('execution.purchaseApply') ? 'purchase' : 'verification',
action: 'execute',
execute_method: isRefundVerification ? 'refund' : values.execute_method,
voucher_files: voucherFileUrls,
remark: values.remark
})
});
if (executeResponse.ok) {
setPendingData(pendingData.filter(item => item.key !== selectedRecord.key));
// 刷新已执行数据
const fetchExecutedData = async () => {
try {
const response = await fetch('/api/executions/executed');
if (response.ok) {
const data = await response.json();
if (data.success && Array.isArray(data.data)) {
setExecutedData(data.data.map((item: any, index: number) => ({
...item,
key: item.id || index,
rawData: item
})));
}
}
} catch (error) {
console.error('获取已执行数据错误:', error);
}
};
fetchExecutedData();
message.success(t('execution.executeSuccess', { code: selectedRecord.code }));
setDetailModalVisible(false);
} else {
message.error(t('execution.executeFailed'));
}
}
} catch (error) {
console.error('执行操作失败:', error);
message.error(t('common.networkError'));
} finally {
setLoading(false);
}
};
// 处理退回
const handleReject = async () => {
try {
setIsRejecting(true);
// 只验证退回原因字段
const values = await form.validateFields(['rejectReason'], { force: true });
setLoading(true);
// 调用退回API
const rejectResponse = await fetch('/api/executions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
apply_id: selectedRecord.id,
apply_type: selectedRecord.type === t('execution.advanceApply') ? 'advance' : selectedRecord.type === t('execution.reimburseApply') ? 'reimbursement' : selectedRecord.type === t('execution.paymentApply') ? 'payment' : selectedRecord.type === t('execution.purchaseApply') ? 'purchase' : 'verification',
action: 'reject',
reject_reason: values.rejectReason
})
});
if (rejectResponse.ok) {
setPendingData(pendingData.filter(item => item.key !== selectedRecord.key));
message.success(t('execution.rejectSuccess', { code: selectedRecord.code }));
setDetailModalVisible(false);
} else {
message.error(t('execution.rejectFailed'));
}
} catch (error) {
console.error('退回操作失败:', error);
message.error(t('common.networkError'));
} finally {
setLoading(false);
}
};
const handleEdit = (record: any) => {
setSelectedRecord(record);
editForm.setFieldsValue({ amount: record.amount, reason: record.reason });
setEditModalVisible(true);
};
const handleEditSubmit = () => {
editForm.validateFields().then(values => {
message.success(t('execution.editSuccess'));
setEditModalVisible(false);
});
};
// 渲染附件列表
const renderAttachments = (attachments: any) => {
// 处理字符串类型的 attachmentsJSON字符串)
let attachmentList = attachments;
if (typeof attachments === 'string') {
try {
attachmentList = JSON.parse(attachments);
} catch (e) {
return null;
}
}
// 确保是数组
if (!Array.isArray(attachmentList) || attachmentList.length === 0) return null;
return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{attachmentList.map((item: any, index: number) => {
// 确保item是字符串类型的URL
const url = typeof item === 'string' ? item : item?.url;
if (!url) return null;
return (
<div key={index} style={{ position: 'relative' }}>
{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' }}>
<FileImageOutlined style={{ fontSize: 32, color: '#999' }} />
</div>
</a>
)}
</div>
);
})}
</div>
);
};
// 获取支出分类的中文名称
const getCategoryName = (category: string) => {
if (!category) return '-';
// 特殊分类映射
const specialCategories: Record<string, string> = {
// Project expense categories
accommodation: t('execution.accommodation'),
food: t('execution.catering'),
fuel: t('execution.fuel'),
materials: t('execution.scatteredMaterial'),
customer_relations: t('execution.customerRelation'),
subcontract_relations: t('execution.subcontractorRelation'),
edl_relations: t('execution.EDLRelation'),
extra_construction: t('execution.extraConstruction'),
// Company expense categories
general_operations: t('execution.generalOperation'),
transportation: t('execution.commute'),
business_expansion: t('execution.marketing'),
power_system_relations: t('execution.powerSystem'),
employee_benefits: t('execution.employeeBenefit'),
express_logistics: t('execution.expressLogistics'),
other: t('execution.otherCategory')
};
// 先检查特殊分类
if (specialCategories[category]) return specialCategories[category];
// 再从项目支出分类中查找
const projectCategory = PROJECT_EXPENSE_CATEGORIES.find(c => c.value === category);
if (projectCategory) return projectCategory.label;
// 再从公司支出分类中查找
const companyCategory = COMPANY_EXPENSE_CATEGORIES.find(c => c.value === category);
if (companyCategory) return companyCategory.label;
// 如果都找不到,返回原始值
return category;
};
// 渲染明细清单
const renderDetailItems = (detailItems: any) => {
// 处理字符串类型的 detailItemsJSON字符串)
let itemsList = detailItems;
if (typeof detailItems === 'string') {
try {
itemsList = JSON.parse(detailItems);
} catch (e) {
return null;
}
}
// 确保是数组
if (!Array.isArray(itemsList) || itemsList.length === 0) return null;
return (
<List
size="small"
bordered
dataSource={itemsList}
renderItem={(item: any, index: number) => (
<List.Item>
<div style={{ width: '100%' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<span><strong>{t('execution.detailLabel', { index: index + 1 })}</strong> {item.description || getCategoryName(item.category) || '-'}</span>
<span style={{ fontWeight: 'bold', color: '#1890ff' }}>{formatAmount(item.amount, item.currency)}</span>
</div>
{item.category && (
<div style={{ marginBottom: 8, fontSize: 13, color: '#666' }}>
<strong>{t('execution.categoryLabel')}</strong>{getCategoryName(item.category)}
</div>
)}
{item.attachments && (
<div style={{ marginTop: 8 }}>
<span style={{ color: '#666', fontSize: 12 }}>{t('execution.detailAttachment')}</span>
{renderAttachments(item.attachments)}
</div>
)}
</div>
</List.Item>
)}
/>
);
};
const pendingColumns = [
{ title: t('execution.subject'), dataIndex: 'reason', key: 'reason', ellipsis: true, render: (v: string, r: any) => <a onClick={() => handleViewDetail(r)}>{v}</a> },
{ title: t('execution.type'), dataIndex: 'type', key: 'type', width: 100, render: (v: string) => getTypeTag(v) },
{ title: t('execution.applicant'), dataIndex: 'applicant', key: 'applicant', width: 80 },
{ title: t('execution.amount'), dataIndex: 'amount', key: 'amount', width: 120, render: (v: number, r: any) => <span style={{ fontWeight: 'bold', color: '#1890ff' }}>{formatAmount(v, r.currency)}</span> },
{ title: t('execution.payee'), dataIndex: 'payee', key: 'payee', ellipsis: true, render: (v: string, r: any) => v || r.applicant },
{ title: t('execution.approvalDate'), dataIndex: 'approveDate', key: 'approveDate', width: 100 },
{ title: t('execution.code'), dataIndex: 'code', key: 'code', width: 140 },
{
title: t('execution.action'), key: 'action', width: 200,
render: (_: any, record: any) => (
<Space wrap>
<Button size="small" type="primary" icon={<DollarOutlined />} onClick={() => handleViewDetail(record)}>{t('execution.execute')}</Button>
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}>{t('execution.edit')}</Button>
</Space>
)
}
];
// 筛选和排序已执行数据
const getFilteredExecutedData = () => {
let data = [...executedData];
// 按事由搜索
if (searchKeyword) {
data = data.filter(item =>
(item.reason || '').toLowerCase().includes(searchKeyword.toLowerCase()) ||
(item.code || '').toLowerCase().includes(searchKeyword.toLowerCase()) ||
(item.applicant || '').toLowerCase().includes(searchKeyword.toLowerCase())
);
}
// 按类型筛选
if (filterType) {
data = data.filter(item => item.type === filterType);
}
// 排序
data.sort((a, b) => {
let aValue = a[sortField];
let bValue = b[sortField];
// 处理日期排序
if (sortField === 'executeDate') {
aValue = a.execute_date || a.executeDate || '';
bValue = b.execute_date || b.executeDate || '';
}
if (sortOrder === 'ascend') {
return aValue > bValue ? 1 : -1;
} else {
return aValue < bValue ? 1 : -1;
}
});
return data;
};
const executedColumns = [
{ title: t('execution.subject'), dataIndex: 'reason', key: 'reason', ellipsis: true, render: (v: string, r: any) => <a onClick={() => handleViewDetail(r)}>{v || '-'}</a> },
{ title: t('execution.type'), dataIndex: 'type', key: 'type', width: 100, render: (v: string) => getTypeTag(v) },
{ title: t('execution.applicant'), dataIndex: 'applicant', key: 'applicant', width: 80 },
{ title: t('execution.amount'), 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: t('execution.executionDate'), dataIndex: 'executeDate', key: 'executeDate', width: 100, sorter: true, render: (v: string) => v || '-' },
{ title: t('execution.executionMethod'), dataIndex: 'executeMethod', key: 'executeMethod', width: 100, render: (v: string) => v || '-' },
{ title: t('execution.status'), dataIndex: 'status', key: 'status', width: 100, render: (v: string) => getStatusTag(v) },
{ title: t('execution.code'), dataIndex: 'code', key: 'code', width: 140 },
];
// 已执行列表的筛选和排序控件
const ExecutedListControls = () => (
<div style={{ marginBottom: 16, display: 'flex', gap: 16, flexWrap: 'wrap' }}>
<Input.Search
placeholder={t('execution.searchPlaceholder')}
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
onSearch={(value) => setSearchKeyword(value)}
style={{ width: 250 }}
allowClear
/>
<Select
placeholder={t('execution.filterType')}
value={filterType}
onChange={(value) => setFilterType(value)}
style={{ width: 150 }}
allowClear
>
<Select.Option value={t('execution.advanceApply')}>{t('execution.advanceApply')}</Select.Option>
<Select.Option value={t('execution.reimburseApply')}>{t('execution.reimburseApply')}</Select.Option>
<Select.Option value={t('execution.paymentApply')}>{t('execution.paymentApply')}</Select.Option>
<Select.Option value={t('execution.verificationApply')}>{t('execution.verificationApply')}</Select.Option>
<Select.Option value={t('execution.purchaseApply')}>{t('execution.purchaseApply')}</Select.Option>
</Select>
<Select
placeholder={t('execution.sortBy')}
value={`${sortField}_${sortOrder}`}
onChange={(value) => {
const [field, order] = (value as string).split('_');
setSortField(field);
setSortOrder(order as 'ascend' | 'descend');
}}
style={{ width: 180 }}
>
<Select.Option value="executeDate_descend">{t('execution.sortDateNew')}</Select.Option>
<Select.Option value="executeDate_ascend">{t('execution.sortDateOld')}</Select.Option>
<Select.Option value="amount_descend">{t('execution.sortAmountHigh')}</Select.Option>
<Select.Option value="amount_ascend">{t('execution.sortAmountLow')}</Select.Option>
</Select>
</div>
);
const tabItems = [
{ key: 'pending', label: <span>{t('execution.pendingTab')} <Badge count={pendingData.length} style={{ marginLeft: 8 }} /></span>, children: <Table columns={pendingColumns} dataSource={pendingData} loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 1300 }} /> },
{ key: 'executed', label: t('execution.executedTab'), children: (
<>
<ExecutedListControls />
<Table
columns={executedColumns}
dataSource={getFilteredExecutedData()}
loading={loading}
pagination={{ pageSize: 10 }}
scroll={{ x: 1400 }}
onChange={(pagination, filters, sorter: any) => {
if (sorter.field) {
setSortField(sorter.field);
setSortOrder(sorter.order || 'descend');
}
}}
/>
</>
)},
];
// 上传配置
const getUploadHeaders = () => {
try {
const authStorage = localStorage.getItem('auth-storage');
if (authStorage) {
const parsed = JSON.parse(authStorage);
const token = parsed?.state?.token;
if (token) return { authorization: `Bearer ${token}` };
}
} catch (e) {}
return {};
};
const uploadProps = {
name: 'file',
action: '/api/upload/single',
headers: getUploadHeaders(),
onChange(info: any) {
// 更新文件列表状态
setVoucherFiles(info.fileList);
if (info.file.status === 'done') {
message.success(t('execution.uploadSuccess', { name: info.file.name }));
// 如果上传成功,将返回的URL添加到文件对象中
const updatedFileList = info.fileList.map((file: any) => {
if (file.uid === info.file.uid && file.response) {
return {
...file,
url: file.response.data?.url || file.response.url || file.response
};
}
return file;
});
setVoucherFiles(updatedFileList);
} else if (info.file.status === 'error') {
message.error(t('execution.uploadFailed', { name: info.file.name }));
}
},
fileList: voucherFiles,
};
return (
<div style={{ padding: 24 }}>
<div style={{ marginBottom: 24 }}><h2 style={{ marginBottom: 8 }}>{t('execution.title')}</h2><p style={{ color: '#888', marginBottom: 0 }}>{t('execution.description')}</p></div>
<Card><Tabs items={tabItems} /></Card>
{/* 详情模态框 */}
<Modal
title={`${selectedRecord?.type}${t('common.detail')}`}
open={detailModalVisible}
onCancel={() => setDetailModalVisible(false)}
width={900}
footer={
selectedRecord?.status === 'approved' || selectedRecord?.status === 'pending' ? (
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<Button onClick={() => setDetailModalVisible(false)}>{t('execution.cancel')}</Button>
<Button danger icon={<CloseOutlined />} onClick={handleReject}>{t('execution.pass')}</Button>
<Button type="primary" icon={<CheckOutlined />} onClick={handleExecute}>{t('execution.execute')}</Button>
</div>
) : (
<Button onClick={() => setDetailModalVisible(false)}>{t('execution.close')}</Button>
)
}
>
{fullDetail && (
<>
{/* 基本信息 */}
<Descriptions bordered column={2} size="small">
<Descriptions.Item label={t('execution.applicationType')}>{getTypeTag(selectedRecord.type)}</Descriptions.Item>
<Descriptions.Item label={t('execution.applicationCode')}>{selectedRecord.code}</Descriptions.Item>
<Descriptions.Item label={t('execution.applicant')}>{selectedRecord.applicant}</Descriptions.Item>
<Descriptions.Item label={t('execution.approvalDate')}>{selectedRecord.date || fullDetail.advance_date || fullDetail.reimbursement_date || fullDetail.payment_date || fullDetail.verification_date}</Descriptions.Item>
<Descriptions.Item label={t('execution.amount')}>
{formatAmount(selectedRecord.amount, selectedRecord.currency)}
{selectedRecord.currency !== 'CNY' && fullDetail.amount_cny > 0 && (
<span style={{ color: '#999', marginLeft: 8 }}> ¥{fullDetail.amount_cny.toLocaleString('zh-CN', {minimumFractionDigits: 2})}</span>
)}
</Descriptions.Item>
<Descriptions.Item label={t('execution.status')}>{getStatusTag(selectedRecord.status)}</Descriptions.Item>
<Descriptions.Item label={t('execution.subject')} span={2}>{selectedRecord.reason}</Descriptions.Item>
{/* 付款申请特有字段 */}
{selectedRecord.type === t('execution.paymentApply') && (
<>
<Descriptions.Item label={t('execution.payeeType')}>
{fullDetail.payee_type === 'subcontractor' ? t('execution.counterpartySubcontractor') :
fullDetail.payee_type === 'supplier' ? t('execution.counterpartySupplier') :
fullDetail.payee_type === 'customer' ? t('execution.counterpartyCustomer') : t('execution.counterpartyOther')}
</Descriptions.Item>
<Descriptions.Item label={t('execution.payee')}>{fullDetail.payee || '-'}</Descriptions.Item>
<Descriptions.Item label={t('execution.bankName')}>{fullDetail.bank_name || '-'}</Descriptions.Item>
<Descriptions.Item label={t('execution.bankAccount')}>{fullDetail.bank_account || '-'}</Descriptions.Item>
<Descriptions.Item label={t('execution.expenseType')}>
{fullDetail.expense_type === 'company' ? t('execution.companyExpense') : t('execution.projectExpense')}
</Descriptions.Item>
{fullDetail.expense_type === 'project' && fullDetail.project_id && (
<Descriptions.Item label={t('execution.relatedProject')}>{getProjectName(fullDetail.project_id)}</Descriptions.Item>
)}
<Descriptions.Item label={t('execution.expenseCategory')}>
{fullDetail.expense_type === 'project'
? (PROJECT_EXPENSE_CATEGORIES.find(c => c.value === fullDetail.expense_category)?.label || fullDetail.expense_category)
: (COMPANY_EXPENSE_CATEGORIES.find(c => c.value === fullDetail.expense_category)?.label || fullDetail.expense_category)
}
</Descriptions.Item>
</>
)}
{/* 报销申请特有字段 */}
{selectedRecord.type === t('execution.reimburseApply') && fullDetail.expense_type && (
<>
<Descriptions.Item label={t('execution.expenseType')}>
{fullDetail.expense_type === 'company' ? t('execution.companyExpense') : t('execution.projectExpense')}
</Descriptions.Item>
{fullDetail.project_id && (
<Descriptions.Item label={t('execution.relatedProject')}>{getProjectName(fullDetail.project_id)}</Descriptions.Item>
)}
</>
)}
{/* 核销申请特有字段 */}
{selectedRecord.type === t('execution.verificationApply') && fullDetail.advance_code && (
<>
<Descriptions.Item label={t('execution.relatedAdvance')}>{fullDetail.advance_code}</Descriptions.Item>
<Descriptions.Item label={t('execution.advanceAmount')}>{formatAmount(fullDetail.advance_amount, fullDetail.currency)}</Descriptions.Item>
<Descriptions.Item label={t('execution.settlement')}>
<span style={{ fontWeight: 'bold', color: fullDetail.settlement ? '#52c41a' : '#fa8c16' }}>
{fullDetail.settlement ? t('common.is') : t('common.no')}
</span>
</Descriptions.Item>
<Descriptions.Item label={t('execution.verifiedAmount')}>{formatAmount(fullDetail.total_reimbursed || 0, fullDetail.currency)}</Descriptions.Item>
<Descriptions.Item label={t('execution.remainingAmount')}>{formatAmount((fullDetail.advance_amount || 0) - (fullDetail.total_reimbursed || 0), fullDetail.currency)}</Descriptions.Item>
{fullDetail.settlement && fullDetail.settlement_amount && (
<Descriptions.Item label={t('execution.settlementAmount')} span={2}>
{fullDetail.settlement_amount > 0 ? `${t('execution.refundText')}${formatAmount(fullDetail.settlement_amount, fullDetail.currency)}` : `${t('execution.supplementText')}${formatAmount(Math.abs(fullDetail.settlement_amount), fullDetail.currency)}`}
</Descriptions.Item>
)}
</>
)}
{/* 采购申请特有字段 */}
{selectedRecord.type === t('execution.purchaseApply') && (
<>
<Descriptions.Item label={t('execution.purchaseType')}>
{fullDetail.purchase_type === 'project' ? t('execution.projectPurchase') : t('execution.stockPurchase')}
</Descriptions.Item>
{fullDetail.purchase_type === 'project' && fullDetail.project_id && (
<Descriptions.Item label={t('execution.relatedProject')}>{getProjectName(fullDetail.project_id)}</Descriptions.Item>
)}
<Descriptions.Item label={t('execution.supplier')}>{fullDetail.supplier_name || '-'}</Descriptions.Item>
<Descriptions.Item label={t('execution.expenseCategory')}>
{fullDetail.expense_category === 'material' ? t('execution.material') :
fullDetail.expense_category === 'equipment' ? t('execution.equipment') :
fullDetail.expense_category === 'pole' ? t('execution.pole') : t('execution.otherCategory')}
</Descriptions.Item>
<Descriptions.Item label={t('execution.currency')}>{fullDetail.currency}</Descriptions.Item>
<Descriptions.Item label={t('execution.approvalDate')}>{fullDetail.request_date}</Descriptions.Item>
<Descriptions.Item label={t('execution.subject')} span={2}>{fullDetail.brief_description || '-'}</Descriptions.Item>
{fullDetail.remark && (
<Descriptions.Item label={t('execution.remark')} span={2}>{fullDetail.remark}</Descriptions.Item>
)}
</>
)}
</Descriptions>
{/* 采购申请供应商收款信息 */}
{selectedRecord.type === t('execution.purchaseApply') && fullDetail.supplier_payment_infos && fullDetail.supplier_payment_infos.length > 0 && (
<>
<Divider>{t('execution.supplierPaymentInfo')}</Divider>
<Descriptions bordered column={2} size="small">
{fullDetail.supplier_payment_infos.filter((p: any) => p.is_primary).map((payment: any, index: number) => (
<React.Fragment key={index}>
<Descriptions.Item label={t('execution.accountName')}>{payment.account_name || '-'}</Descriptions.Item>
<Descriptions.Item label={t('execution.bankAccount')}>{payment.bank_account || '-'}</Descriptions.Item>
<Descriptions.Item label={t('execution.bankName')}>{payment.bank_name || '-'}</Descriptions.Item>
{payment.qr_code && (
<Descriptions.Item label={t('execution.qrCode')}>
<img src={payment.qr_code} alt={t('execution.qrCode')} style={{ width: 100, height: 100, objectFit: 'contain' }} />
</Descriptions.Item>
)}
</React.Fragment>
))}
</Descriptions>
</>
)}
{/* 采购申请商品明细 */}
{selectedRecord.type === t('execution.purchaseApply') && fullDetail.items && fullDetail.items.length > 0 && (
<>
<Divider>{t('execution.purchaseDetail')}</Divider>
<List
size="small"
bordered
dataSource={fullDetail.items}
renderItem={(item: any, index: number) => (
<List.Item>
<div style={{ width: '100%' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<span><strong>{index + 1}. {item.product_name}</strong></span>
<span style={{ fontWeight: 'bold', color: '#1890ff' }}>
{fullDetail.currency} {item.total_price?.toLocaleString('zh-CN', {minimumFractionDigits: 2})}
</span>
</div>
<div style={{ fontSize: 13, color: '#666' }}>
{t('execution.specLabel')}{item.specification || '-'} | {t('execution.unitLabel')}{item.unit || '-'} |
{t('execution.qtyLabel')}{item.quantity} | {t('execution.priceLabel')}{fullDetail.currency} {item.unit_price?.toLocaleString('zh-CN', {minimumFractionDigits: 2})}
</div>
</div>
</List.Item>
)}
/>
</>
)}
{/* 明细清单 */}
{fullDetail.detail_items && fullDetail.detail_items.length > 0 && (
<>
<Divider>{t('execution.detailList')}</Divider>
{renderDetailItems(fullDetail.detail_items)}
</>
)}
{/* 审批意见 */}
{fullDetail.approval_remark && (
<>
<Divider>{t('execution.approvalOpinion')}</Divider>
<div style={{ padding: '12px', background: '#f5f5f5', borderRadius: '4px' }}>
<p style={{ margin: 0, color: '#666' }}>{fullDetail.approval_remark}</p>
</div>
</>
)}
{/* 申请凭证附件或退款凭证 */}
{fullDetail.attachments && fullDetail.attachments.length > 0 && (
<>
<Divider>{fullDetail.settlement && fullDetail.settlement_amount > 0 ? t('execution.refundProof') : t('execution.applicationAttachment')}</Divider>
{renderAttachments(fullDetail.attachments)}
</>
)}
{/* 执行表单 */}
{(selectedRecord.status === 'approved' || selectedRecord.status === 'pending') && (
<>
<Divider>{t('execution.executionInfo')}</Divider>
<Form form={form} layout="vertical">
{/* 执行/确认日期 */}
<Form.Item name="execute_date" label={selectedRecord.type === t('execution.verificationApply') && fullDetail.settlement && fullDetail.settlement_amount > 0 ? t('execution.confirmationDate') : t('execution.executionDate')} rules={[{ required: true }]}>
<DatePicker style={{ width: '100%' }} />
</Form.Item>
{/* 执行方式或收款方式 - 非结算核销不需要 */}
{!(selectedRecord.type === t('execution.verificationApply') && !fullDetail.settlement) && (
selectedRecord.type === t('execution.verificationApply') && fullDetail.settlement && fullDetail.settlement_amount > 0 ? (
<Form.Item name="execute_method" label={t('execution.paymentMethod')} rules={[{ required: true }]}>
<Select options={[{ value: 'bank', label: t('execution.bankTransfer') }, { value: 'cash', label: t('execution.cash') }, { value: 'wechat', label: t('execution.wechat') }, { value: 'other', label: t('execution.other') }]} />
</Form.Item>
) : (
<Form.Item name="execute_method" label={t('execution.executionMethodLabel')} rules={[{ required: true }]}>
<Select options={[{ value: 'bank', label: t('execution.bankTransfer') }, { value: 'cash', label: t('execution.cash') }, { value: 'wechat', label: t('execution.wechat') }, { value: 'other', label: t('execution.other') }]} />
</Form.Item>
)
)}
{/* 只有退款类型的核销申请和非结算核销不需要上传付款凭证,其他类型的申请都需要 */}
{!(selectedRecord.type === t('execution.verificationApply') && (fullDetail.settlement && fullDetail.settlement_amount > 0 || !fullDetail.settlement)) && (
<Form.Item label={t('execution.proofOfPayment')} required>
<Upload {...uploadProps}>
<Button icon={<UploadOutlined />}>{t('execution.uploadProof')}</Button>
</Upload>
<div style={{ marginTop: 8, color: '#666', fontSize: 12 }}>
{t('execution.proofUploadTip')}
</div>
</Form.Item>
)}
{/* 退款的核销申请显示提示 */}
{selectedRecord.type === t('execution.verificationApply') && fullDetail.settlement && fullDetail.settlement_amount > 0 && (
<div style={{ margin: '16px 0', padding: '12px', backgroundColor: '#f6ffed', border: '1px solid #b7eb8f', borderRadius: '4px' }}>
<p style={{ margin: 0, color: '#389e0d' }}>{t('execution.noProofRefund')}</p>
</div>
)}
{/* 非结算核销的核销申请显示提示 */}
{selectedRecord.type === t('execution.verificationApply') && !fullDetail.settlement && (
<div style={{ margin: '16px 0', padding: '12px', backgroundColor: '#e6f7ff', border: '1px solid #91d5ff', borderRadius: '4px' }}>
<p style={{ margin: 0, color: '#1890ff' }}>{t('execution.noProofNonSettlement')}</p>
</div>
)}
{/* 根据操作类型显示不同的字段 */}
{!isRejecting ? (
// 执行操作时显示的字段
<>
{/* 收款确认信息(仅退款类型)或备注 */}
{selectedRecord.type === t('execution.verificationApply') && fullDetail.settlement && fullDetail.settlement_amount > 0 ? (
<Form.Item name="remark" label={t('execution.paymentConfirmation')} rules={[{ required: true, message: t('execution.confirmRequired') }]}>
<TextArea rows={3} placeholder={t('execution.confirmationPlaceholder')} />
</Form.Item>
) : (
<Form.Item name="remark" label={t('execution.remark')}>
<TextArea rows={2} placeholder={t('execution.remarkPlaceholder')} />
</Form.Item>
)}
</>
) : (
// 退回操作时显示的字段
<Form.Item name="rejectReason" label={t('execution.returnReason')} rules={[{ required: true, message: t('execution.rejectReasonRequired') }]}>
<TextArea rows={3} placeholder={t('execution.returnReason')} />
</Form.Item>
)}
</Form>
</>
)}
</>
)}
</Modal>
<Modal title={`${t('execution.edit')}${selectedRecord?.code}`} open={editModalVisible} onCancel={() => setEditModalVisible(false)} onOk={handleEditSubmit} width={600}>
<Form form={editForm} layout="vertical">
<Form.Item label={t('execution.applicationType')}><Input value={selectedRecord?.type} disabled /></Form.Item>
<Form.Item label={t('execution.applicant')}><Input value={selectedRecord?.applicant} disabled /></Form.Item>
<Form.Item name="amount" label={t('execution.amount')} rules={[{ required: true }]}><Input type="number" style={{ width: '100%' }} /></Form.Item>
<Form.Item name="reason" label={t('execution.subject')} rules={[{ required: true }]}><TextArea rows={3} /></Form.Item>
</Form>
</Modal>
</div>
);
};
export default ExecutionManagement;