Files
yunhaifinance/frontend/src/src/pages/advances/AdvancesPage.tsx
T

438 lines
18 KiB
TypeScript

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;